Manage plugins from the user interface and from the command line

This commit is contained in:
Frederic Guillot 2016-05-20 12:51:05 -04:00
parent cbf896e74e
commit 8d69c49da5
20 changed files with 563 additions and 61 deletions

View File

@ -3,6 +3,7 @@ Version 1.0.29 (unreleased)
New features:
* Manage plugin from the user interface and from the command line
* Added the possibility to convert a subtask to a task
* Added menu entry to add tasks from all project views
* Add tasks in bulk from the board

View File

@ -26,6 +26,8 @@ use Symfony\Component\Console\Command\Command;
* @property \Kanboard\Model\UserNotification $userNotification
* @property \Kanboard\Model\UserNotificationFilter $userNotificationFilter
* @property \Kanboard\Model\ProjectUserRole $projectUserRole
* @property \Kanboard\Core\Plugin\Loader $pluginLoader
* @property \Kanboard\Core\Http\Client $httpClient
* @property \Symfony\Component\EventDispatcher\EventDispatcher $dispatcher
*/
abstract class BaseCommand extends Command

View File

@ -0,0 +1,35 @@
<?php
namespace Kanboard\Console;
use Kanboard\Core\Plugin\Installer;
use Kanboard\Core\Plugin\PluginInstallerException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class PluginInstallCommand extends BaseCommand
{
protected function configure()
{
$this
->setName('plugin:install')
->setDescription('Install a plugin from a remote Zip archive')
->addArgument('url', InputArgument::REQUIRED, 'Archive URL');
}
protected function execute(InputInterface $input, OutputInterface $output)
{
if (!Installer::isConfigured()) {
$output->writeln('<error>Kanboard is not configured to install plugins itself</error>');
}
try {
$installer = new Installer($this->container);
$installer->install($input->getArgument('url'));
$output->writeln('<info>Plugin installed successfully</info>');
} catch (PluginInstallerException $e) {
$output->writeln('<error>'.$e->getMessage().'</error>');
}
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace Kanboard\Console;
use Kanboard\Core\Plugin\Installer;
use Kanboard\Core\Plugin\PluginInstallerException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class PluginUninstallCommand extends BaseCommand
{
protected function configure()
{
$this
->setName('plugin:uninstall')
->setDescription('Remove a plugin')
->addArgument('pluginId', InputArgument::REQUIRED, 'Plugin directory name');
}
protected function execute(InputInterface $input, OutputInterface $output)
{
if (!Installer::isConfigured()) {
$output->writeln('<error>Kanboard is not configured to remove plugins itself</error>');
}
try {
$installer = new Installer($this->container);
$installer->uninstall($input->getArgument('pluginId'));
$output->writeln('<info>Plugin removed successfully</info>');
} catch (PluginInstallerException $e) {
$output->writeln('<error>'.$e->getMessage().'</error>');
}
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace Kanboard\Console;
use Kanboard\Core\Plugin\Base as BasePlugin;
use Kanboard\Core\Plugin\Installer;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class PluginUpgradeCommand extends BaseCommand
{
protected function configure()
{
$this
->setName('plugin:upgrade')
->setDescription('Update all installed plugins')
;
}
protected function execute(InputInterface $input, OutputInterface $output)
{
if (!Installer::isConfigured()) {
$output->writeln('<error>Kanboard is not configured to upgrade plugins itself</error>');
}
$installer = new Installer($this->container);
$availablePlugins = $this->httpClient->getJson(PLUGIN_API_URL);
foreach ($this->pluginLoader->getPlugins() as $installedPlugin) {
$pluginDetails = $this->getPluginDetails($availablePlugins, $installedPlugin);
if ($pluginDetails === null) {
$output->writeln('<error>* Plugin not available in the directory: '.$installedPlugin->getPluginName().'</error>');
} elseif ($pluginDetails['version'] > $installedPlugin->getPluginVersion()) {
$output->writeln('<comment>* Updating plugin: '.$installedPlugin->getPluginName().'</comment>');
$installer->update($pluginDetails['download']);
} else {
$output->writeln('<info>* Plugin up to date: '.$installedPlugin->getPluginName().'</info>');
}
}
}
protected function getPluginDetails(array $availablePlugins, BasePlugin $installedPlugin)
{
foreach ($availablePlugins as $availablePlugin) {
if ($availablePlugin['title'] === $installedPlugin->getPluginName()) {
return $availablePlugin;
}
}
return null;
}
}

View File

@ -2,6 +2,9 @@
namespace Kanboard\Controller;
use Kanboard\Core\Plugin\Installer;
use Kanboard\Core\Plugin\PluginInstallerException;
/**
* Class PluginController
*
@ -18,8 +21,9 @@ class PluginController extends BaseController
public function show()
{
$this->response->html($this->helper->layout->plugin('plugin/show', array(
'plugins' => $this->pluginLoader->plugins,
'plugins' => $this->pluginLoader->getPlugins(),
'title' => t('Installed Plugins'),
'is_configured' => Installer::isConfigured(),
)));
}
@ -28,11 +32,94 @@ class PluginController extends BaseController
*/
public function directory()
{
$plugins = $this->httpClient->getJson(PLUGIN_API_URL);
$installedPlugins = array();
foreach ($this->pluginLoader->getPlugins() as $plugin) {
$installedPlugins[$plugin->getPluginName()] = $plugin->getPluginVersion();
}
$this->response->html($this->helper->layout->plugin('plugin/directory', array(
'plugins' => $plugins,
'installed_plugins' => $installedPlugins,
'available_plugins' => $this->httpClient->getJson(PLUGIN_API_URL),
'title' => t('Plugin Directory'),
'is_configured' => Installer::isConfigured(),
)));
}
/**
* Install plugin from URL
*
* @throws \Kanboard\Core\Controller\AccessForbiddenException
*/
public function install()
{
$this->checkCSRFParam();
$pluginArchiveUrl = urldecode($this->request->getStringParam('archive_url'));
try {
$installer = new Installer($this->container);
$installer->install($pluginArchiveUrl);
$this->flash->success(t('Plugin installed successfully.'));
} catch (PluginInstallerException $e) {
$this->flash->failure($e->getMessage());
}
$this->response->redirect($this->helper->url->to('PluginController', 'show'));
}
/**
* Update plugin from URL
*
* @throws \Kanboard\Core\Controller\AccessForbiddenException
*/
public function update()
{
$this->checkCSRFParam();
$pluginArchiveUrl = urldecode($this->request->getStringParam('archive_url'));
try {
$installer = new Installer($this->container);
$installer->update($pluginArchiveUrl);
$this->flash->success(t('Plugin updated successfully.'));
} catch (PluginInstallerException $e) {
$this->flash->failure($e->getMessage());
}
$this->response->redirect($this->helper->url->to('PluginController', 'show'));
}
/**
* Confirmation before to remove the plugin
*/
public function confirm()
{
$pluginId = $this->request->getStringParam('pluginId');
$plugins = $this->pluginLoader->getPlugins();
$this->response->html($this->template->render('plugin/remove', array(
'plugin_id' => $pluginId,
'plugin' => $plugins[$pluginId],
)));
}
/**
* Remove a plugin
*
* @throws \Kanboard\Core\Controller\AccessForbiddenException
*/
public function uninstall()
{
$this->checkCSRFParam();
$pluginId = $this->request->getStringParam('pluginId');
try {
$installer = new Installer($this->container);
$installer->uninstall($pluginId);
$this->flash->success(t('Plugin removed successfully.'));
} catch (PluginInstallerException $e) {
$this->flash->failure($e->getMessage());
}
$this->response->redirect($this->helper->url->to('PluginController', 'show'));
}
}

View File

@ -0,0 +1,162 @@
<?php
namespace Kanboard\Core\Plugin;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ZipArchive;
/**
* Class Installer
*
* @package Kanboard\Core\Plugin
* @author Frederic Guillot
*/
class Installer extends \Kanboard\Core\Base
{
/**
* Return true if Kanboard is configured to install plugins
*
* @static
* @access public
* @return bool
*/
public static function isConfigured()
{
return PLUGIN_INSTALLER && is_writable(PLUGINS_DIR) && extension_loaded('zip');
}
/**
* Install a plugin
*
* @access public
* @param string $archiveUrl
* @throws PluginInstallerException
*/
public function install($archiveUrl)
{
$zip = $this->downloadPluginArchive($archiveUrl);
if (! $zip->extractTo(PLUGINS_DIR)) {
$this->cleanupArchive($zip);
throw new PluginInstallerException(t('Unable to extract plugin archive.'));
}
$this->cleanupArchive($zip);
}
/**
* Uninstall a plugin
*
* @access public
* @param string $pluginId
* @throws PluginInstallerException
*/
public function uninstall($pluginId)
{
$pluginFolder = PLUGINS_DIR.DIRECTORY_SEPARATOR.basename($pluginId);
if (! file_exists($pluginFolder)) {
throw new PluginInstallerException(t('Plugin not found.'));
}
if (! is_writable($pluginFolder)) {
throw new PluginInstallerException(e('You don\'t have the permission to remove this plugin.'));
}
$this->removeAllDirectories($pluginFolder);
}
/**
* Update a plugin
*
* @access public
* @param string $archiveUrl
* @throws PluginInstallerException
*/
public function update($archiveUrl)
{
$zip = $this->downloadPluginArchive($archiveUrl);
$firstEntry = $zip->statIndex(0);
$this->uninstall($firstEntry['name']);
if (! $zip->extractTo(PLUGINS_DIR)) {
$this->cleanupArchive($zip);
throw new PluginInstallerException(t('Unable to extract plugin archive.'));
}
$this->cleanupArchive($zip);
}
/**
* Download archive from URL
*
* @access protected
* @param string $archiveUrl
* @return ZipArchive
* @throws PluginInstallerException
*/
protected function downloadPluginArchive($archiveUrl)
{
$zip = new ZipArchive();
$archiveData = $this->httpClient->get($archiveUrl);
$archiveFile = tempnam(sys_get_temp_dir(), 'kb_plugin');
if (empty($archiveData)) {
unlink($archiveFile);
throw new PluginInstallerException(t('Unable to download plugin archive.'));
}
if (file_put_contents($archiveFile, $archiveData) === false) {
unlink($archiveFile);
throw new PluginInstallerException(t('Unable to write temporary file for plugin.'));
}
if ($zip->open($archiveFile) !== true) {
unlink($archiveFile);
throw new PluginInstallerException(t('Unable to open plugin archive.'));
}
if ($zip->numFiles === 0) {
unlink($archiveFile);
throw new PluginInstallerException(t('There is no file in the plugin archive.'));
}
return $zip;
}
/**
* Remove archive file
*
* @access protected
* @param ZipArchive $zip
*/
protected function cleanupArchive(ZipArchive $zip)
{
unlink($zip->filename);
$zip->close();
}
/**
* Remove recursively a directory
*
* @access protected
* @param string $directory
*/
protected function removeAllDirectories($directory)
{
$it = new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS);
$files = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
if ($file->isDir()) {
rmdir($file->getRealPath());
} else {
unlink($file->getRealPath());
}
}
rmdir($directory);
}
}

View File

@ -18,16 +18,16 @@ class Loader extends \Kanboard\Core\Base
/**
* Plugin instances
*
* @access public
* @access protected
* @var array
*/
public $plugins = array();
protected $plugins = array();
/**
* Get list of loaded plugins
*
* @access public
* @return array
* @return Base[]
*/
public function getPlugins()
{
@ -52,7 +52,7 @@ class Loader extends \Kanboard\Core\Base
if ($fileInfo->isDir() && substr($fileInfo->getFilename(), 0, 1) !== '.') {
$pluginName = $fileInfo->getFilename();
$this->loadSchema($pluginName);
$this->initializePlugin($this->loadPlugin($pluginName));
$this->initializePlugin($pluginName, $this->loadPlugin($pluginName));
}
}
}
@ -95,9 +95,10 @@ class Loader extends \Kanboard\Core\Base
* Initialize plugin
*
* @access public
* @param Base $plugin
* @param string $pluginName
* @param Base $plugin
*/
public function initializePlugin(Base $plugin)
public function initializePlugin($pluginName, Base $plugin)
{
if (method_exists($plugin, 'onStartup')) {
$this->dispatcher->addListener('app.bootstrap', array($plugin, 'onStartup'));
@ -107,6 +108,6 @@ class Loader extends \Kanboard\Core\Base
Tool::buildDICHelpers($this->container, $plugin->getHelpers());
$plugin->initialize();
$this->plugins[] = $plugin;
$this->plugins[$pluginName] = $plugin;
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace Kanboard\Core\Plugin;
use Exception;
/**
* Class PluginInstallerException
*
* @package Kanboard\Core\Plugin
* @author Frederic Guillot
*/
class PluginInstallerException extends Exception
{
}

View File

@ -178,7 +178,7 @@ class RouteProvider implements ServiceProviderInterface
// Plugins
$container['route']->addRoute('extensions', 'PluginController', 'show');
$container['route']->addRoute('extensions/list', 'PluginController', 'directory');
$container['route']->addRoute('extensions/directory', 'PluginController', 'directory');
// Doc
$container['route']->addRoute('documentation/:file', 'doc', 'show');

View File

@ -37,7 +37,7 @@
</li>
<?php if ($this->user->hasProjectAccess('TaskCreationController', 'show', $column['project_id'])): ?>
<li>
<i class="fa fa-align-justify" aria-hidden="true"></i>
<i class="fa fa-align-justify fa-fw" aria-hidden="true"></i>
<?= $this->url->link(t('Create tasks in bulk'), 'TaskBulkController', 'show', array('project_id' => $column['project_id'], 'column_id' => $column['id'], 'swimlane_id' => $swimlane['id']), false, 'popover') ?>
</li>
<?php if ($column['nb_tasks'] > 0): ?>

View File

@ -2,29 +2,54 @@
<h2><?= t('Plugin Directory') ?></h2>
</div>
<?php if (empty($plugins)): ?>
<?php if (! $is_configured): ?>
<p class="alert alert-error">
<?= t('Your Kanboard instance is not configured to install plugins from the user interface.') ?>
</p>
<?php endif ?>
<?php if (empty($available_plugins)): ?>
<p class="alert"><?= t('There is no plugin available.') ?></p>
<?php else: ?>
<table class="table-stripped">
<?php foreach ($available_plugins as $plugin): ?>
<table>
<tr>
<th class="column-20"><?= t('Name') ?></th>
<th class="column-20"><?= t('Author') ?></th>
<th class="column-10"><?= t('Version') ?></th>
<th><?= t('Description') ?></th>
<th><?= t('Action') ?></th>
<th colspan="3">
<a href="<?= $plugin['homepage'] ?>" target="_blank" rel="noreferrer"><?= $this->text->e($plugin['title']) ?></a>
</th>
</tr>
<tr>
<td class="column-40">
<?= $this->text->e($plugin['author']) ?>
</td>
<td class="column-30">
<?= $this->text->e($plugin['version']) ?>
</td>
<td>
<?php if ($is_configured): ?>
<?php if (! isset($installed_plugins[$plugin['title']])): ?>
<i class="fa fa-cloud-download fa-fw" aria-hidden="true"></i>
<?= $this->url->link(t('Install'), 'PluginController', 'install', array('archive_url' => urlencode($plugin['download'])), true) ?>
<?php elseif ($installed_plugins[$plugin['title']] < $plugin['version']): ?>
<i class="fa fa-refresh fa-fw" aria-hidden="true"></i>
<?= $this->url->link(t('Update'), 'PluginController', 'update', array('archive_url' => urlencode($plugin['download'])), true) ?>
<?php else: ?>
<i class="fa fa-check-circle-o" aria-hidden="true"></i>
<?= t('Up to date') ?>
<?php endif ?>
<?php else: ?>
<i class="fa fa-ban fa-fw" aria-hidden="true"></i>
<?= t('Not available') ?>
<?php endif ?>
</td>
</tr>
<tr>
<td colspan="3">
<div class="markdown">
<?= $this->text->markdown($plugin['description']) ?>
</div>
</td>
</tr>
<?php foreach ($plugins as $plugin): ?>
<tr>
<td>
<a href="<?= $plugin['homepage'] ?>" target="_blank" rel="noreferrer"><?= $this->text->e($plugin['title']) ?></a>
</td>
<td><?= $this->text->e($plugin['author']) ?></td>
<td><?= $this->text->e($plugin['version']) ?></td>
<td><?= $this->text->e($plugin['description']) ?></td>
<td>
</td>
</tr>
<?php endforeach ?>
</table>
<?php endforeach ?>
<?php endif ?>

View File

@ -0,0 +1,13 @@
<div class="page-header">
<h2><?= t('Remove plugin') ?></h2>
</div>
<div class="confirm">
<p class="alert alert-info"><?= t('Do you really want to remove this plugin: "%s"?', $plugin->getPluginName()) ?></p>
<div class="form-actions">
<?= $this->url->link(t('Yes'), 'PluginController', 'uninstall', array('pluginId' => $plugin_id), true, 'btn btn-red') ?>
<?= t('or') ?>
<?= $this->url->link(t('cancel'), 'PluginController', 'show', array(), false, 'close-popover') ?>
</div>
</div>

View File

@ -5,15 +5,17 @@
<?php if (empty($plugins)): ?>
<p class="alert"><?= t('There is no plugin loaded.') ?></p>
<?php else: ?>
<table class="table-stripped">
<table>
<tr>
<th class="column-20"><?= t('Name') ?></th>
<th class="column-20"><?= t('Author') ?></th>
<th class="column-35"><?= t('Name') ?></th>
<th class="column-30"><?= t('Author') ?></th>
<th class="column-10"><?= t('Version') ?></th>
<th><?= t('Description') ?></th>
<?php if ($is_configured): ?>
<th><?= t('Action') ?></th>
<?php endif ?>
</tr>
<?php foreach ($plugins as $plugin): ?>
<?php foreach ($plugins as $pluginFolder => $plugin): ?>
<tr>
<td>
<?php if ($plugin->getPluginHomepage()): ?>
@ -24,7 +26,15 @@
</td>
<td><?= $this->text->e($plugin->getPluginAuthor()) ?></td>
<td><?= $this->text->e($plugin->getPluginVersion()) ?></td>
<td><?= $this->text->e($plugin->getPluginDescription()) ?></td>
<?php if ($is_configured): ?>
<td>
<i class="fa fa-trash-o fa-fw" aria-hidden="true"></i>
<?= $this->url->link(t('Uninstall'), 'PluginController', 'confirm', array('pluginId' => $pluginFolder), false, 'popover') ?>
</td>
<?php endif ?>
</tr>
<tr>
<td colspan="<?= $is_configured ? 4 : 3 ?>"><?= $this->text->e($plugin->getPluginDescription()) ?></td>
</tr>
<?php endforeach ?>
</table>

View File

@ -12,8 +12,10 @@ defined('DATA_DIR') or define('DATA_DIR', ROOT_DIR.DIRECTORY_SEPARATOR.'data');
// Files directory (attachments)
defined('FILES_DIR') or define('FILES_DIR', DATA_DIR.DIRECTORY_SEPARATOR.'files');
// Plugins directory
// Plugins settings
defined('PLUGINS_DIR') or define('PLUGINS_DIR', ROOT_DIR.DIRECTORY_SEPARATOR.'plugins');
defined('PLUGIN_API_URL') or define('PLUGIN_API_URL', 'https://kanboard.net/plugins.json');
defined('PLUGIN_INSTALLER') or define('PLUGIN_INSTALLER', true);
// Enable/disable debug
defined('DEBUG') or define('DEBUG', strtolower(getenv('DEBUG')) === 'true');
@ -131,5 +133,3 @@ defined('HTTP_PROXY_HOSTNAME') or define('HTTP_PROXY_HOSTNAME', '');
defined('HTTP_PROXY_PORT') or define('HTTP_PROXY_PORT', '3128');
defined('HTTP_PROXY_USERNAME') or define('HTTP_PROXY_USERNAME', '');
defined('HTTP_PROXY_PASSWORD') or define('HTTP_PROXY_PASSWORD', '');
defined('PLUGIN_API_URL') or define('PLUGIN_API_URL', 'https://kanboard.net/plugins.json');

View File

@ -41,6 +41,10 @@ Available commands:
locale:sync Synchronize all translations based on the fr_FR locale
notification
notification:overdue-tasks Send notifications for overdue tasks
plugin
plugin:install Install a plugin from a remote Zip archive
plugin:uninstall Remove a plugin
plugin:upgrade Update all installed plugins
projects
projects:daily-stats Calculate daily statistics for all projects
trigger
@ -170,3 +174,25 @@ You will be prompted for a password and confirmation. Characters are not printed
```bash
./kanboard user:reset-2fa my_user
```
### Install a plugin
```bash
./kanboard plugin:install https://github.com/kanboard/plugin-github-auth/releases/download/v1.0.1/GithubAuth-1.0.1.zip
```
Note: Installed files will have the same permissions as the current user
### Remove a plugin
```bash
./kanboard plugin:uninstall Budget
```
### Upgrade all plugins
```bash
./kanboard plugin:upgrade
* Updating plugin: Budget Planning
* Plugin up to date: Github Authentication
```

View File

@ -15,14 +15,21 @@ define('LOG_DRIVER', 'file'); // Other drivers are: syslog, stdout, stderr or fi
The log driver must be defined if you enable the debug mode.
The debug mode logs all SQL queries and the time taken to generate pages.
Plugins folder
--------------
Plugins
-------
Plugin folder:
```php
// Plugin directory
define('PLUGINS_DIR', 'data/plugins');
```
Enable/disable plugin installation from the user interface:
```php
define('PLUGIN_INSTALLER', true); // Default is true
```
Folder for uploaded files
-------------------------

View File

@ -110,6 +110,7 @@ Technical details
- [Environment variables](env.markdown)
- [Email configuration](email-configuration.markdown)
- [URL rewriting](nice-urls.markdown)
- [Plugin Directory](plugin-directory.markdown)
### Database

View File

@ -0,0 +1,15 @@
Plugin Directory Configuration
==============================
To install, update and remove plugins from the user interface, you must have those requirements:
- The plugin directory must be writeable by the web server user
- The Zip extension must be available on your server
- The config parameter `PLUGIN_INSTALLER` must be set at `true`
To disable this feature, change the value of `PLUGIN_INSTALLER` to `false` in your config file.
You can also change the permissions of the plugin folder on the filesystem.
Only administrators are allowed to install plugins from the user interface.
By default, only plugin listed on Kanboard's website are available.

View File

@ -1,8 +1,9 @@
#!/usr/bin/env php
<?php
require __DIR__.'/app/common.php';
use Kanboard\Console\PluginInstallCommand;
use Kanboard\Console\PluginUninstallCommand;
use Kanboard\Console\PluginUpgradeCommand;
use Kanboard\Console\ResetPasswordCommand;
use Kanboard\Console\ResetTwoFactorCommand;
use Symfony\Component\Console\Application;
@ -18,19 +19,32 @@ use Kanboard\Console\LocaleComparatorCommand;
use Kanboard\Console\TaskTriggerCommand;
use Kanboard\Console\CronjobCommand;
$container['dispatcher']->dispatch('app.bootstrap', new Event);
$application = new Application('Kanboard', APP_VERSION);
$application->add(new TaskOverdueNotificationCommand($container));
$application->add(new SubtaskExportCommand($container));
$application->add(new TaskExportCommand($container));
$application->add(new ProjectDailyStatsCalculationCommand($container));
$application->add(new ProjectDailyColumnStatsExportCommand($container));
$application->add(new TransitionExportCommand($container));
$application->add(new LocaleSyncCommand($container));
$application->add(new LocaleComparatorCommand($container));
$application->add(new TaskTriggerCommand($container));
$application->add(new CronjobCommand($container));
$application->add(new ResetPasswordCommand($container));
$application->add(new ResetTwoFactorCommand($container));
$application->run();
try {
require __DIR__.'/app/common.php';
$container['dispatcher']->dispatch('app.bootstrap', new Event);
$application = new Application('Kanboard', APP_VERSION);
$application->add(new TaskOverdueNotificationCommand($container));
$application->add(new SubtaskExportCommand($container));
$application->add(new TaskExportCommand($container));
$application->add(new ProjectDailyStatsCalculationCommand($container));
$application->add(new ProjectDailyColumnStatsExportCommand($container));
$application->add(new TransitionExportCommand($container));
$application->add(new LocaleSyncCommand($container));
$application->add(new LocaleComparatorCommand($container));
$application->add(new TaskTriggerCommand($container));
$application->add(new CronjobCommand($container));
$application->add(new ResetPasswordCommand($container));
$application->add(new ResetTwoFactorCommand($container));
$application->add(new PluginUpgradeCommand($container));
$application->add(new PluginInstallCommand($container));
$application->add(new PluginUninstallCommand($container));
$application->run();
} catch (Exception $e) {
echo $e->getMessage().PHP_EOL;
exit(255);
}