diff --git a/bin/cli b/bin/cli index 66086b23..263df59e 100755 --- a/bin/cli +++ b/bin/cli @@ -5,7 +5,4 @@ use Symfony\Component\Console\Application as CliApp; /** @var ContainerInterface $container */ $container = include __DIR__ . '/../config/container.php'; - -/** @var CliApp $app */ -$app = $container->get(CliApp::class); -$app->run(); +$container->get(CliApp::class)->run(); diff --git a/bin/install b/bin/install index b4147e10..43a07cd3 100755 --- a/bin/install +++ b/bin/install @@ -2,6 +2,9 @@ [ Application::class => InstallApplicationFactory::class, + Filesystem::class => InvokableFactory::class, + QuestionHelper::class => InvokableFactory::class, ]]); $container->build(Application::class)->run(); diff --git a/bin/update b/bin/update index 13c2a684..164e20b0 100755 --- a/bin/update +++ b/bin/update @@ -2,6 +2,9 @@ [ Application::class => InstallApplicationFactory::class, + Filesystem::class => InvokableFactory::class, + QuestionHelper::class => InvokableFactory::class, ]]); $container->build(Application::class, ['isUpdate' => true])->run(); diff --git a/module/CLI/src/Command/Install/InstallCommand.php b/module/CLI/src/Command/Install/InstallCommand.php index e30785af..641fb552 100644 --- a/module/CLI/src/Command/Install/InstallCommand.php +++ b/module/CLI/src/Command/Install/InstallCommand.php @@ -1,9 +1,10 @@ 'pdo_mysql', - 'PostgreSQL' => 'pdo_pgsql', - 'SQLite' => 'pdo_sqlite', - ]; - const SUPPORTED_LANGUAGES = ['en', 'es']; const GENERATED_CONFIG_PATH = 'config/params/generated_config.php'; /** @@ -46,22 +38,22 @@ class InstallCommand extends Command * @var ProcessHelper */ private $processHelper; - /** - * @var string - */ - private $importedInstallationPath; /** * @var WriterInterface */ private $configWriter; - /** - * @var bool - */ - private $isUpdate; /** * @var Filesystem */ private $filesystem; + /** + * @var ConfigCustomizerPluginManagerInterface + */ + private $configCustomizers; + /** + * @var bool + */ + private $isUpdate; /** * InstallCommand constructor. @@ -70,18 +62,24 @@ class InstallCommand extends Command * @param bool $isUpdate * @throws LogicException */ - public function __construct(WriterInterface $configWriter, Filesystem $filesystem, $isUpdate = false) - { + public function __construct( + WriterInterface $configWriter, + Filesystem $filesystem, + ConfigCustomizerPluginManagerInterface $configCustomizers, + $isUpdate = false + ) { parent::__construct(); $this->configWriter = $configWriter; $this->isUpdate = $isUpdate; $this->filesystem = $filesystem; + $this->configCustomizers = $configCustomizers; } public function configure() { - $this->setName('shlink:install') - ->setDescription('Installs Shlink'); + $this + ->setName('shlink:install') + ->setDescription('Installs or updates Shlink'); } public function execute(InputInterface $input, OutputInterface $output) @@ -118,10 +116,16 @@ class InstallCommand extends Command $config = $this->isUpdate ? $this->importConfig() : new CustomizableAppConfig(); // Ask for custom config params - $this->askDatabase($config); - $this->askUrlShortener($config); - $this->askLanguage($config); - $this->askApplication($config); + foreach ([ + Plugin\DatabaseConfigCustomizerPlugin::class, + Plugin\UrlShortenerConfigCustomizerPlugin::class, + Plugin\LanguageConfigCustomizerPlugin::class, + Plugin\ApplicationConfigCustomizerPlugin::class, + ] as $pluginName) { + /** @var Plugin\ConfigCustomizerPluginInterface $configCustomizer */ + $configCustomizer = $this->configCustomizers->get($pluginName); + $configCustomizer->process($input, $output, $config); + } // Generate config params files $this->configWriter->toFile(self::GENERATED_CONFIG_PATH, $config->getArrayCopy(), false); @@ -170,10 +174,10 @@ class InstallCommand extends Command // Ask the user for the older shlink path $keepAsking = true; do { - $this->importedInstallationPath = $this->ask( + $config->setImportedInstallationPath($this->ask( 'Previous shlink installation path from which to import config' - ); - $configFile = $this->importedInstallationPath . '/' . self::GENERATED_CONFIG_PATH; + )); + $configFile = $config->getImportedInstallationPath() . '/' . self::GENERATED_CONFIG_PATH; $configExists = $this->filesystem->exists($configFile); if (! $configExists) { @@ -194,151 +198,6 @@ class InstallCommand extends Command return $config; } - protected function askDatabase(CustomizableAppConfig $config) - { - $this->printTitle('DATABASE'); - - if ($config->hasDatabase()) { - $keepConfig = $this->questionHelper->ask($this->input, $this->output, new ConfirmationQuestion( - 'Do you want to keep imported database config? (Y/n): ' - )); - if ($keepConfig) { - // If the user selected to keep DB config and is configured to use sqlite, copy DB file - if ($config->getDatabase()['DRIVER'] === self::DATABASE_DRIVERS['SQLite']) { - $this->filesystem->copy( - $this->importedInstallationPath . '/' . CustomizableAppConfig::SQLITE_DB_PATH, - CustomizableAppConfig::SQLITE_DB_PATH - ); - } - - return; - } - } - - // Select database type - $params = []; - $databases = array_keys(self::DATABASE_DRIVERS); - $dbType = $this->questionHelper->ask($this->input, $this->output, new ChoiceQuestion( - 'Select database type (defaults to ' . $databases[0] . '):', - $databases, - 0 - )); - $params['DRIVER'] = self::DATABASE_DRIVERS[$dbType]; - - // Ask for connection params if database is not SQLite - if ($params['DRIVER'] !== self::DATABASE_DRIVERS['SQLite']) { - $params['NAME'] = $this->ask('Database name', 'shlink'); - $params['USER'] = $this->ask('Database username'); - $params['PASSWORD'] = $this->ask('Database password'); - $params['HOST'] = $this->ask('Database host', 'localhost'); - $params['PORT'] = $this->ask('Database port', $this->getDefaultDbPort($params['DRIVER'])); - } - - $config->setDatabase($params); - } - - protected function getDefaultDbPort($driver) - { - return $driver === 'pdo_mysql' ? '3306' : '5432'; - } - - protected function askUrlShortener(CustomizableAppConfig $config) - { - $this->printTitle('URL SHORTENER'); - - if ($config->hasUrlShortener()) { - $keepConfig = $this->questionHelper->ask($this->input, $this->output, new ConfirmationQuestion( - 'Do you want to keep imported URL shortener config? (Y/n): ' - )); - if ($keepConfig) { - return; - } - } - - // Ask for URL shortener params - $config->setUrlShortener([ - 'SCHEMA' => $this->questionHelper->ask($this->input, $this->output, new ChoiceQuestion( - 'Select schema for generated short URLs (defaults to http):', - ['http', 'https'], - 0 - )), - 'HOSTNAME' => $this->ask('Hostname for generated URLs'), - 'CHARS' => $this->ask( - 'Character set for generated short codes (leave empty to autogenerate one)', - null, - true - ) ?: str_shuffle(UrlShortener::DEFAULT_CHARS) - ]); - } - - protected function askLanguage(CustomizableAppConfig $config) - { - $this->printTitle('LANGUAGE'); - - if ($config->hasLanguage()) { - $keepConfig = $this->questionHelper->ask($this->input, $this->output, new ConfirmationQuestion( - 'Do you want to keep imported language? (Y/n): ' - )); - if ($keepConfig) { - return; - } - } - - $config->setLanguage([ - 'DEFAULT' => $this->questionHelper->ask($this->input, $this->output, new ChoiceQuestion( - 'Select default language for the application in general (defaults to ' - . self::SUPPORTED_LANGUAGES[0] . '):', - self::SUPPORTED_LANGUAGES, - 0 - )), - 'CLI' => $this->questionHelper->ask($this->input, $this->output, new ChoiceQuestion( - 'Select default language for CLI executions (defaults to ' - . self::SUPPORTED_LANGUAGES[0] . '):', - self::SUPPORTED_LANGUAGES, - 0 - )), - ]); - } - - protected function askApplication(CustomizableAppConfig $config) - { - $this->printTitle('APPLICATION'); - - if ($config->hasApp()) { - $keepConfig = $this->questionHelper->ask($this->input, $this->output, new ConfirmationQuestion( - 'Do you want to keep imported application config? (Y/n): ' - )); - if ($keepConfig) { - return; - } - } - - $config->setApp([ - 'SECRET' => $this->ask( - 'Define a secret string that will be used to sign API tokens (leave empty to autogenerate one)', - null, - true - ) ?: $this->generateRandomString(32), - ]); - } - - /** - * @param string $text - */ - protected function printTitle($text) - { - $text = trim($text); - $length = strlen($text) + 4; - $header = str_repeat('*', $length); - - $this->output->writeln([ - '', - '' . $header . '', - '* ' . strtoupper($text) . ' *', - '' . $header . '', - ]); - } - /** * @param string $text * @param string|null $default diff --git a/module/CLI/src/Factory/InstallApplicationFactory.php b/module/CLI/src/Factory/InstallApplicationFactory.php index 2c5b5236..e4d4beb9 100644 --- a/module/CLI/src/Factory/InstallApplicationFactory.php +++ b/module/CLI/src/Factory/InstallApplicationFactory.php @@ -3,10 +3,15 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Factory; +use Acelaya\ZsmAnnotatedServices\Factory\V3\AnnotatedFactory; use Interop\Container\ContainerInterface; use Interop\Container\Exception\ContainerException; use Shlinkio\Shlink\CLI\Command\Install\InstallCommand; +use Shlinkio\Shlink\CLI\Install\ConfigCustomizerPluginManager; +use Shlinkio\Shlink\CLI\Install\Plugin; +use Shlinkio\Shlink\CLI\Install\Plugin\Factory\DefaultConfigCustomizerPluginFactory; use Symfony\Component\Console\Application; +use Symfony\Component\Console\Exception\LogicException; use Symfony\Component\Filesystem\Filesystem; use Zend\Config\Writer\PhpArray; use Zend\ServiceManager\Exception\ServiceNotCreatedException; @@ -22,6 +27,7 @@ class InstallApplicationFactory implements FactoryInterface * @param string $requestedName * @param null|array $options * @return object + * @throws LogicException * @throws ServiceNotFoundException if unable to resolve the service. * @throws ServiceNotCreatedException if an exception is raised when * creating a service. @@ -32,7 +38,17 @@ class InstallApplicationFactory implements FactoryInterface $isUpdate = $options !== null && isset($options['isUpdate']) ? (bool) $options['isUpdate'] : false; $app = new Application(); - $command = new InstallCommand(new PhpArray(), new Filesystem(), $isUpdate); + $command = new InstallCommand( + new PhpArray(), + $container->get(Filesystem::class), + new ConfigCustomizerPluginManager($container, ['factories' => [ + Plugin\DatabaseConfigCustomizerPlugin::class => AnnotatedFactory::class, + Plugin\UrlShortenerConfigCustomizerPlugin::class => DefaultConfigCustomizerPluginFactory::class, + Plugin\LanguageConfigCustomizerPlugin::class => DefaultConfigCustomizerPluginFactory::class, + Plugin\ApplicationConfigCustomizerPlugin::class => DefaultConfigCustomizerPluginFactory::class, + ]]), + $isUpdate + ); $app->add($command); $app->setDefaultCommand($command->getName()); diff --git a/module/CLI/src/Install/ConfigCustomizerPluginManager.php b/module/CLI/src/Install/ConfigCustomizerPluginManager.php new file mode 100644 index 00000000..c8f0e7cb --- /dev/null +++ b/module/CLI/src/Install/ConfigCustomizerPluginManager.php @@ -0,0 +1,10 @@ +questionHelper = $questionHelper; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @param string $text + * @param string|null $default + * @param bool $allowEmpty + * @return string + * @throws RuntimeException + */ + protected function ask(InputInterface $input, OutputInterface $output, $text, $default = null, $allowEmpty = false) + { + if ($default !== null) { + $text .= ' (defaults to ' . $default . ')'; + } + do { + $value = $this->questionHelper->ask($input, $output, new Question( + '' . $text . ': ', + $default + )); + if (empty($value) && ! $allowEmpty) { + $output->writeln('Value can\'t be empty'); + } + } while (empty($value) && $default === null && ! $allowEmpty); + + return $value; + } + + /** + * @param OutputInterface $output + * @param string $text + */ + protected function printTitle(OutputInterface $output, $text) + { + $text = trim($text); + $length = strlen($text) + 4; + $header = str_repeat('*', $length); + + $output->writeln([ + '', + '' . $header . '', + '* ' . strtoupper($text) . ' *', + '' . $header . '', + ]); + } +} diff --git a/module/CLI/src/Install/Plugin/ApplicationConfigCustomizerPlugin.php b/module/CLI/src/Install/Plugin/ApplicationConfigCustomizerPlugin.php new file mode 100644 index 00000000..78fda68a --- /dev/null +++ b/module/CLI/src/Install/Plugin/ApplicationConfigCustomizerPlugin.php @@ -0,0 +1,46 @@ +printTitle($output, 'APPLICATION'); + + if ($appConfig->hasApp()) { + $keepConfig = $this->questionHelper->ask($input, $output, new ConfirmationQuestion( + 'Do you want to keep imported application config? (Y/n): ' + )); + if ($keepConfig) { + return; + } + } + + $appConfig->setApp([ + 'SECRET' => $this->ask( + $input, + $output, + 'Define a secret string that will be used to sign API tokens (leave empty to autogenerate one)', + null, + true + ) ?: $this->generateRandomString(32), + ]); + } +} diff --git a/module/CLI/src/Install/Plugin/ConfigCustomizerPluginInterface.php b/module/CLI/src/Install/Plugin/ConfigCustomizerPluginInterface.php new file mode 100644 index 00000000..2f1c60e1 --- /dev/null +++ b/module/CLI/src/Install/Plugin/ConfigCustomizerPluginInterface.php @@ -0,0 +1,17 @@ + 'pdo_mysql', + 'PostgreSQL' => 'pdo_pgsql', + 'SQLite' => 'pdo_sqlite', + ]; + + /** + * @var Filesystem + */ + private $filesystem; + + /** + * DatabaseConfigCustomizerPlugin constructor. + * @param QuestionHelper $questionHelper + * @param Filesystem $filesystem + * + * @DI\Inject({QuestionHelper::class, Filesystem::class}) + */ + public function __construct(QuestionHelper $questionHelper, Filesystem $filesystem) + { + parent::__construct($questionHelper); + $this->filesystem = $filesystem; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @param CustomizableAppConfig $appConfig + * @return void + * @throws IOException + * @throws RuntimeException + */ + public function process(InputInterface $input, OutputInterface $output, CustomizableAppConfig $appConfig) + { + $this->printTitle($output, 'DATABASE'); + + if ($appConfig->hasDatabase()) { + $keepConfig = $this->questionHelper->ask($input, $output, new ConfirmationQuestion( + 'Do you want to keep imported database config? (Y/n): ' + )); + if ($keepConfig) { + // If the user selected to keep DB config and is configured to use sqlite, copy DB file + if ($appConfig->getDatabase()['DRIVER'] === self::DATABASE_DRIVERS['SQLite']) { + try { + $this->filesystem->copy( + $appConfig->getImportedInstallationPath() . '/' . CustomizableAppConfig::SQLITE_DB_PATH, + CustomizableAppConfig::SQLITE_DB_PATH + ); + } catch (IOException $e) { + $output->writeln('It wasn\'t possible to import the SQLite database'); + throw $e; + } + } + + return; + } + } + + // Select database type + $params = []; + $databases = array_keys(self::DATABASE_DRIVERS); + $dbType = $this->questionHelper->ask($input, $output, new ChoiceQuestion( + 'Select database type (defaults to ' . $databases[0] . '):', + $databases, + 0 + )); + $params['DRIVER'] = self::DATABASE_DRIVERS[$dbType]; + + // Ask for connection params if database is not SQLite + if ($params['DRIVER'] !== self::DATABASE_DRIVERS['SQLite']) { + $params['NAME'] = $this->ask($input, $output, 'Database name', 'shlink'); + $params['USER'] = $this->ask($input, $output, 'Database username'); + $params['PASSWORD'] = $this->ask($input, $output, 'Database password'); + $params['HOST'] = $this->ask($input, $output, 'Database host', 'localhost'); + $params['PORT'] = $this->ask($input, $output, 'Database port', $this->getDefaultDbPort($params['DRIVER'])); + } + + $appConfig->setDatabase($params); + } + + private function getDefaultDbPort($driver) + { + return $driver === 'pdo_mysql' ? '3306' : '5432'; + } +} diff --git a/module/CLI/src/Install/Plugin/Factory/DefaultConfigCustomizerPluginFactory.php b/module/CLI/src/Install/Plugin/Factory/DefaultConfigCustomizerPluginFactory.php new file mode 100644 index 00000000..6e1ea7a0 --- /dev/null +++ b/module/CLI/src/Install/Plugin/Factory/DefaultConfigCustomizerPluginFactory.php @@ -0,0 +1,31 @@ +get(QuestionHelper::class)); + } +} diff --git a/module/CLI/src/Install/Plugin/LanguageConfigCustomizerPlugin.php b/module/CLI/src/Install/Plugin/LanguageConfigCustomizerPlugin.php new file mode 100644 index 00000000..3259883a --- /dev/null +++ b/module/CLI/src/Install/Plugin/LanguageConfigCustomizerPlugin.php @@ -0,0 +1,52 @@ +printTitle($output, 'LANGUAGE'); + + if ($appConfig->hasLanguage()) { + $keepConfig = $this->questionHelper->ask($input, $output, new ConfirmationQuestion( + 'Do you want to keep imported language? (Y/n): ' + )); + if ($keepConfig) { + return; + } + } + + $appConfig->setLanguage([ + 'DEFAULT' => $this->questionHelper->ask($input, $output, new ChoiceQuestion( + 'Select default language for the application in general (defaults to ' + . self::SUPPORTED_LANGUAGES[0] . '):', + self::SUPPORTED_LANGUAGES, + 0 + )), + 'CLI' => $this->questionHelper->ask($input, $output, new ChoiceQuestion( + 'Select default language for CLI executions (defaults to ' + . self::SUPPORTED_LANGUAGES[0] . '):', + self::SUPPORTED_LANGUAGES, + 0 + )), + ]); + } +} diff --git a/module/CLI/src/Install/Plugin/UrlShortenerConfigCustomizerPlugin.php b/module/CLI/src/Install/Plugin/UrlShortenerConfigCustomizerPlugin.php new file mode 100644 index 00000000..7c0dec48 --- /dev/null +++ b/module/CLI/src/Install/Plugin/UrlShortenerConfigCustomizerPlugin.php @@ -0,0 +1,53 @@ +printTitle($output, 'URL SHORTENER'); + + if ($appConfig->hasUrlShortener()) { + $keepConfig = $this->questionHelper->ask($input, $output, new ConfirmationQuestion( + 'Do you want to keep imported URL shortener config? (Y/n): ' + )); + if ($keepConfig) { + return; + } + } + + // Ask for URL shortener params + $appConfig->setUrlShortener([ + 'SCHEMA' => $this->questionHelper->ask($input, $output, new ChoiceQuestion( + 'Select schema for generated short URLs (defaults to http):', + ['http', 'https'], + 0 + )), + 'HOSTNAME' => $this->ask($input, $output, 'Hostname for generated URLs'), + 'CHARS' => $this->ask( + $input, + $output, + 'Character set for generated short codes (leave empty to autogenerate one)', + null, + true + ) ?: str_shuffle(UrlShortener::DEFAULT_CHARS) + ]); + } +} diff --git a/module/CLI/src/Model/CustomizableAppConfig.php b/module/CLI/src/Model/CustomizableAppConfig.php index a1f48bbd..03836e0f 100644 --- a/module/CLI/src/Model/CustomizableAppConfig.php +++ b/module/CLI/src/Model/CustomizableAppConfig.php @@ -23,6 +23,10 @@ final class CustomizableAppConfig implements ArraySerializableInterface * @var array */ private $app; + /** + * @var string + */ + private $importedInstallationPath; /** * @return array @@ -128,6 +132,32 @@ final class CustomizableAppConfig implements ArraySerializableInterface return ! empty($this->app); } + /** + * @return string + */ + public function getImportedInstallationPath() + { + return $this->importedInstallationPath; + } + + /** + * @param string $importedInstallationPath + * @return $this|self + */ + public function setImportedInstallationPath($importedInstallationPath) + { + $this->importedInstallationPath = $importedInstallationPath; + return $this; + } + + /** + * @return bool + */ + public function hasImportedInstallationPath() + { + return $this->importedInstallationPath !== null; + } + /** * Exchange internal values from provided array *