diff --git a/Api/Data/ModuleInterface.php b/Api/Data/ModuleInterface.php deleted file mode 100644 index 9144a63..0000000 --- a/Api/Data/ModuleInterface.php +++ /dev/null @@ -1,115 +0,0 @@ -setName('swissup:auth:add') + ->setDescription('Add SwissupLabs access key') + ->addArgument(self::INPUT_ARGUMENT_KEY, InputArgument::REQUIRED, 'Access key'); + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $key = trim($input->getArgument(self::INPUT_ARGUMENT_KEY)); + + try { + if (in_array($key, $this->repository->getKeys(), true)) { + $output->writeln('This key is already added'); + return Cli::RETURN_SUCCESS; + } + + $packages = $this->repository->addKey($key); + $output->writeln(sprintf( + 'Key accepted. Packages available with this key: %d', + count($packages) + )); + + if (!$this->repository->isEnabled()) { + $output->writeln( + 'SwissupLabs repository is not enabled. Run bin/magento swissup:channel:enable' + ); + } + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + return Cli::RETURN_FAILURE; + } + + return Cli::RETURN_SUCCESS; + } +} diff --git a/Console/Command/Installer/AuthCheckCommand.php b/Console/Command/Installer/AuthCheckCommand.php new file mode 100644 index 0000000..6f69f68 --- /dev/null +++ b/Console/Command/Installer/AuthCheckCommand.php @@ -0,0 +1,140 @@ +setName('swissup:auth:check') + ->setDescription('Display SwissupLabs access keys with the number of packages available'); + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + try { + $keys = $this->repository->getKeys(); + + if (!$keys) { + $output->writeln('No access keys found. Run bin/magento swissup:auth:add {key} to add one.'); + return Cli::RETURN_SUCCESS; + } + + $username = $this->repository->getUsername(); + $latest = $this->remote->getComponentsInfo(); + $packages = $this->repository->getPackagesBatch($username, $keys); + + $table = new Table($output); + $table->setHeaders(['Username', 'Key', 'Provider', 'Packages']); + + foreach ($keys as $i => $key) { + $summary = $packages[$key] instanceof \Exception + ? '' . $packages[$key]->getMessage() . '' + : $this->summarize($packages[$key], $latest); + + $table->addRow([ + $i ? '' : $username, + $key, + $this->repository->getKeyDomain($key) ?: '', + $summary, + ]); + } + + $table->render(); + + $this->cleanup($input, $output, $username, $keys); + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + return Cli::RETURN_FAILURE; + } + + return Cli::RETURN_SUCCESS; + } + + /** + * Offer to drop the duplicates from the saved credentials + * + * @param InputInterface $input + * @param OutputInterface $output + * @param string $username + * @param string[] $keys + * @return void + * @throws \RuntimeException + */ + private function cleanup(InputInterface $input, OutputInterface $output, $username, array $keys) + { + $unique = array_unique($keys); + $duplicates = count($keys) - count($unique); + + if (!$duplicates) { + return; + } + + $question = new ConfirmationQuestion( + sprintf('%d duplicate key(s) found. Remove duplicates? [Y/n] ', $duplicates), + true + ); + + if (!$this->getHelper('question')->ask($input, $output, $question)) { + return; + } + + $this->repository->saveCredentials($username, implode(' ', $unique)); + $output->writeln('Duplicate keys were removed'); + } + + /** + * @param array $packages + * @param array $latest + * @return string + */ + private function summarize(array $packages, array $latest) + { + $outdated = 0; + + foreach ($packages as $name => $versions) { + if (empty($latest[$name]['version'])) { + continue; + } + + $version = $this->getLatestVersion(array_keys($versions)); + if ($version && version_compare($version, $latest[$name]['version'], '<')) { + $outdated++; + } + } + + if (!$outdated) { + return (string) count($packages); + } + + return sprintf('%d (%d requires renewal)', count($packages), $outdated); + } + + /** + * @param string[] $versions + * @return string + */ + private function getLatestVersion(array $versions) + { + $versions = array_filter($versions, fn ($version) => strpos($version, 'dev-') !== 0); + usort($versions, 'version_compare'); + + return (string) end($versions); + } +} diff --git a/Console/Command/Installer/AuthRemoveCommand.php b/Console/Command/Installer/AuthRemoveCommand.php new file mode 100644 index 0000000..0815adb --- /dev/null +++ b/Console/Command/Installer/AuthRemoveCommand.php @@ -0,0 +1,49 @@ +setName('swissup:auth:remove') + ->setDescription('Remove SwissupLabs access key') + ->addArgument(self::INPUT_ARGUMENT_KEY, InputArgument::REQUIRED, 'Access key'); + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $key = trim($input->getArgument(self::INPUT_ARGUMENT_KEY)); + + try { + if (!$this->repository->removeKey($key)) { + $output->writeln(sprintf( + 'The key is saved in global composer auth.json. Remove it with:%s', + "\n composer config --global --unset http-basic." . ComposerRepository::HOSTNAME + )); + return Cli::RETURN_FAILURE; + } + + $output->writeln('The key was removed'); + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + return Cli::RETURN_FAILURE; + } + + return Cli::RETURN_SUCCESS; + } +} diff --git a/Console/Command/Installer/AuthShowCommand.php b/Console/Command/Installer/AuthShowCommand.php new file mode 100644 index 0000000..9f53edd --- /dev/null +++ b/Console/Command/Installer/AuthShowCommand.php @@ -0,0 +1,43 @@ +setName('swissup:auth:show') + ->setDescription('Display SwissupLabs access keys currently in use'); + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + try { + $keys = $this->repository->getKeys(); + + if (!$keys) { + $output->writeln('No access keys found. Run bin/magento swissup:auth:add {key} to add one.'); + return Cli::RETURN_SUCCESS; + } + + $output->writeln('Username: ' . $this->repository->getUsername()); + $output->writeln('Password: ' . implode(' ', $keys)); + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + return Cli::RETURN_FAILURE; + } + + return Cli::RETURN_SUCCESS; + } +} diff --git a/Console/Command/Installer/ChannelDisableCommand.php b/Console/Command/Installer/ChannelDisableCommand.php new file mode 100644 index 0000000..0427b53 --- /dev/null +++ b/Console/Command/Installer/ChannelDisableCommand.php @@ -0,0 +1,47 @@ +setName('swissup:channel:disable') + ->setDescription('Remove SwissupLabs repository from composer.json file'); + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + try { + if (!$this->repository->isEnabled()) { + $output->writeln('Repository is not enabled'); + return Cli::RETURN_SUCCESS; + } + + $this->repository->disable(); + $output->writeln('Repository was disabled'); + + if ($this->repository->getKeys()) { + $output->writeln( + 'Access keys are kept. Run bin/magento swissup:auth:remove {key} to remove them.' + ); + } + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + return Cli::RETURN_FAILURE; + } + + return Cli::RETURN_SUCCESS; + } +} diff --git a/Console/Command/Installer/ChannelEnableCommand.php b/Console/Command/Installer/ChannelEnableCommand.php new file mode 100644 index 0000000..098efb2 --- /dev/null +++ b/Console/Command/Installer/ChannelEnableCommand.php @@ -0,0 +1,112 @@ +setName('swissup:channel:enable') + ->setDescription('Add SwissupLabs repository to composer.json file'); + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + try { + $username = $this->repository->getUsername(); + $keys = $this->repository->getKeys(); + $newKey = null; + $packages = null; + + if ($keys) { + $output->writeln('Currently used access keys:'); + foreach ($keys as $key) { + $domain = $this->repository->getKeyDomain($key); + $output->writeln(' - ' . ($domain ? sprintf('%s: %s', $domain, $key) : $key)); + } + + $question = new ConfirmationQuestion('Would you like to add another key? [y/N] ', false); + if ($input->isInteractive() && $this->getHelper('question')->ask($input, $output, $question)) { + $newKey = $this->askKey($username, $input, $output); + } + } else { + $newKey = $this->askKey($username, $input, $output); + } + + if ($newKey !== null && in_array($newKey, $keys, true)) { + $output->writeln('This key is already added'); + } elseif ($newKey !== null) { + $packages = $this->repository->addKey($newKey); + $output->writeln(sprintf( + 'Key accepted. Packages available with this key: %d', + count($packages) + )); + + $keys[] = $newKey; + } + + if ($packages === null || count($keys) > 1) { + $packages = $this->repository->getPackages($username, implode(' ', $keys)); + } + $output->writeln(sprintf('Available packages: %d', count($packages))); + + if ($this->repository->isEnabled()) { + $output->writeln('Repository is already enabled'); + } else { + $this->repository->enable(); + $output->writeln('Repository was enabled'); + } + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + return Cli::RETURN_FAILURE; + } + + return Cli::RETURN_SUCCESS; + } + + /** + * @param string $username + * @param InputInterface $input + * @param OutputInterface $output + * @return string + * @throws \RuntimeException + */ + private function askKey($username, InputInterface $input, OutputInterface $output) + { + if (!$input->isInteractive()) { + throw new \RuntimeException(sprintf('Access key for "%s" is required', $username)); + } + + $output->writeln([ + '1. Navigate to your account on the site where you\'ve made a purchase:', + ' - https://argentotheme.com/license/customer/activation/', + ' - https://firecheckout.net/license/customer/activation/', + ' - https://swissuplabs.com/license/customer/activation/', + sprintf('2. Activate %s domain.', $username), + '3. Copy your access key and paste it here.', + ]); + + $question = new Question(sprintf('Access key for %s: ', $username)); + $question->setValidator(function ($value) { + if (trim((string) $value) === '') { + throw new \RuntimeException('Value cannot be empty'); + } + return trim($value); + }); + + return $this->getHelper('question')->ask($input, $output, $question); + } +} diff --git a/Console/Command/Installer/PackageAbstractCommand.php b/Console/Command/Installer/PackageAbstractCommand.php new file mode 100644 index 0000000..b8fdc69 --- /dev/null +++ b/Console/Command/Installer/PackageAbstractCommand.php @@ -0,0 +1,285 @@ +composer->isDevInstalled()) { + $args[] = '--no-dev'; + } + + return $args; + } + + /** + * @param array $manualCommands Commands to run when the store cannot be changed + * @param boolean $isDryRun + * @return void + * @throws \RuntimeException + */ + protected function validate(array $manualCommands, $isDryRun) + { + if (!$this->process->canRun()) { + throw new \RuntimeException(sprintf( + "proc_open is disabled. Run the commands manually:\n %s", + implode("\n ", $manualCommands) + )); + } + + if (!$isDryRun && $this->appState->getMode() === State::MODE_PRODUCTION) { + $manualCommands[] = 'bin/magento setup:di:compile'; + $manualCommands[] = 'bin/magento setup:static-content:deploy'; + + throw new \RuntimeException(sprintf( + "Production mode detected. Use your deployment process instead:\n %s", + implode("\n ", $manualCommands) + )); + } + } + + /** + * Offer to run swissup:channel:enable when repository or access key is missing + * + * @param InputInterface $input + * @param OutputInterface $output + * @return boolean + * @throws \RuntimeException + */ + protected function ensureRepositoryEnabled(InputInterface $input, OutputInterface $output) + { + if ($this->repository->isEnabled()) { + $credentials = $this->repository->getCredentials(); + if ($credentials['username'] && $credentials['password']) { + return true; + } + $message = 'Access key is not found.'; + } else { + $message = 'SwissupLabs repository is not enabled.'; + } + + if (!$input->isInteractive()) { + throw new \RuntimeException($message . ' Run bin/magento swissup:channel:enable first.'); + } + + $question = new ConfirmationQuestion( + sprintf('%s Run swissup:channel:enable now? [Y/n] ', $message), + true + ); + if (!$this->getHelper('question')->ask($input, $output, $question)) { + return false; + } + + return $this->getApplication() + ->find('swissup:channel:enable') + ->run(new ArrayInput([]), $output) === Cli::RETURN_SUCCESS; + } + + /** + * 1. Resolve dependencies and update composer.lock (store is online) + * 2. Install packages and run setup:upgrade (store is in maintenance mode) + * + * @param array $args Composer require/remove arguments + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + protected function runAndUpgrade(array $args, InputInterface $input, OutputInterface $output) + { + $interactive = $input->isInteractive(); + $installArgs = $this->getInstallArgs(); // must be read before vendor is changed + $backup = $this->composer->backupFiles(); + + // composer reverts composer.json and composer.lock itself on failure + if ($this->composer->run(array_merge($args, ['--no-install']), $output, $interactive)) { + return Cli::RETURN_FAILURE; + } + + $enableMaintenance = !$this->maintenanceMode->isOn(); + $keepMaintenance = false; + + if ($enableMaintenance) { + $output->writeln('Enabling maintenance mode'); + $this->maintenanceMode->set(true); + } + + try { + if ($this->composer->run($installArgs, $output, $interactive)) { + $keepMaintenance = !$this->rollback($backup, $installArgs, $output, $interactive); + return Cli::RETURN_FAILURE; + } + + // Stale generated code and config cache may prevent bin/magento from booting + $output->writeln('Cleaning generated code and cache'); + $this->cleanupFiles->clearCodeGeneratedFiles(); + $this->cacheManager->clean($this->cacheManager->getAvailableTypes()); + + $output->writeln('Running setup:upgrade'); + $dumps = $this->getSchemaDumps(); + // safe-mode to dump the DB data of the disabled modules + $code = $this->process->run( + [BP . '/bin/magento', 'setup:upgrade', '--safe-mode=1'], + $output, + $interactive + ); + $this->notifyAboutSchemaDumps($dumps, $output); + + if ($code) { + // new code with outdated database - keep the store closed + $keepMaintenance = true; + $output->writeln( + 'setup:upgrade failed. Maintenance mode is still enabled. ' . + 'Fix the error, run bin/magento setup:upgrade and bin/magento maintenance:disable' + ); + return Cli::RETURN_FAILURE; + } + + $output->writeln('Done'); + return Cli::RETURN_SUCCESS; + } finally { + if ($enableMaintenance && !$keepMaintenance) { + $output->writeln('Disabling maintenance mode'); + $this->maintenanceMode->set(false); + } + } + } + + /** + * Csv dumps created by setup:upgrade --safe-mode + * + * @return array Path => modification signature + */ + private function getSchemaDumps() + { + clearstatcache(); + $result = []; + + foreach (glob($this->getSchemaDumpsDir() . '/*.csv') ?: [] as $path) { + $result[$path] = filemtime($path) . ':' . filesize($path); + } + + return $result; + } + + /** + * Dumps are never cleaned up and contain raw table data + * + * @param array $before Result of getSchemaDumps() + * @param OutputInterface $output + * @return void + */ + private function notifyAboutSchemaDumps(array $before, OutputInterface $output) + { + $created = array_diff_assoc($this->getSchemaDumps(), $before); + if (!$created) { + return; + } + + $output->writeln(sprintf( + 'Magento removed some data during setup:upgrade. We used --safe-mode=1 to save it to %s:', + $this->getSchemaDumpsDir() + )); + + foreach (array_keys($created) as $path) { + $output->writeln(' - ' . basename($path)); + } + + $output->writeln( + 'Restore it with bin/magento setup:upgrade --data-restore=1 ' . + 'or delete the files - they are kept forever otherwise.' + ); + } + + /** + * @return string + */ + private function getSchemaDumpsDir() + { + return $this->directoryList->getPath(DirectoryList::VAR_DIR) . '/' . Csv::DUMP_FOLDER; + } + + /** + * Restore composer.json, composer.lock and vendor directory + * + * @param array $backup + * @param array $installArgs + * @param OutputInterface $output + * @param boolean $interactive + * @return boolean + */ + protected function rollback(array $backup, array $installArgs, OutputInterface $output, $interactive) + { + $output->writeln('Installation failed. Restoring composer.json, composer.lock and vendor'); + + try { + $this->composer->restoreFiles($backup); + $code = $this->composer->run($installArgs, $output, $interactive); + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + $code = 1; + } + + if ($code) { + $output->writeln( + 'Rollback failed. Maintenance mode is still enabled. ' . + 'Fix the error, run composer install and bin/magento maintenance:disable' + ); + return false; + } + + $output->writeln('Rollback completed'); + return true; + } +} diff --git a/Console/Command/Installer/PackageInstallCommand.php b/Console/Command/Installer/PackageInstallCommand.php new file mode 100644 index 0000000..48d236c --- /dev/null +++ b/Console/Command/Installer/PackageInstallCommand.php @@ -0,0 +1,399 @@ +input = $input; + $this->output = $output; + $this->logger = new ConsoleLogger($output, [ + LogLevel::NOTICE => OutputInterface::VERBOSITY_NORMAL, + LogLevel::INFO => OutputInterface::VERBOSITY_NORMAL, + ]); + + if (!function_exists('exec') || !function_exists('shell_exec')) { + if (method_exists(QuestionHelper::class, 'disableStty')) { + QuestionHelper::disableStty(); + } + } + + parent::initialize($input, $output); + } + + /** + * {@inheritdoc} + */ + protected function configure(): void + { + $this->setName('swissup:package:install') + ->setDescription('Run installer of the downloaded SwissupLabs package(s)'); + + $this->addArgument( + PackageAbstractCommand::INPUT_ARGUMENT_PACKAGES, + InputArgument::IS_ARRAY | InputArgument::REQUIRED, + 'Package name(s) and installer answers: swissup/firecheckout firecheckout_theme=light' + ); + $this->addOption( + self::INPUT_KEY_STORE, + null, + InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL, + 'Store ID' + ); + $this->addOption( + 'commands', + null, + InputOption::VALUE_NONE, + 'Show available commands' + ); + $this->addOption( + self::INPUT_KEY_NO_DOWNLOAD, + null, + InputOption::VALUE_NONE, + 'Do not offer to download the packages missing in the codebase' + ); + + parent::configure(); + } + + protected function getArguments() + { + return $this->input->getArgument(PackageAbstractCommand::INPUT_ARGUMENT_PACKAGES); + } + + protected function getPackages() + { + $packages = []; + + foreach ($this->getArguments() as $argument) { + if (strpos($argument, '=') !== false || strpos($argument, '/') === false) { + continue; + } + $packages[] = $argument; + } + + if (!$packages) { + throw new \RuntimeException('Package name is missing'); + } + + return $packages; + } + + protected function getRequestParams($commands) + { + $params = []; + + foreach ($this->getArguments() as $argument) { + if (strpos($argument, '/') !== false) { + continue; + } + + if (strpos($argument, '=') === false && !in_array($argument, $commands)) { + continue; + } + + $parts = explode('=', $argument); + + if (isset($params[$parts[0]])) { + if (!is_array($params[$parts[0]])) { + $params[$parts[0]] = [$params[$parts[0]]]; + } + $params[$parts[0]][] = $parts[1] ?? ''; + } else { + $params[$parts[0]] = $parts[1] ?? ''; + } + } + + return $params; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $packages = $this->getPackages(); + + if (!$this->installer->hasInstaller($packages)) { + $missing = $this->getMissingPackages($packages); + + if ($missing && $this->confirmDownload($missing)) { + return $this->download($missing); + } + + $output->writeln('Installer file is not found.'); + return \Magento\Framework\Console\Cli::RETURN_FAILURE; + } + + $commands = $this->installer->getCommandAliases($packages); + if ($input->getOption('commands')) { + $commandsStr = implode(', ', $commands); + $output->writeln("Available commands: {$commandsStr}"); + return \Magento\Framework\Console\Cli::RETURN_SUCCESS; + } + + $storeIds = $this->getStoreIds(); + $formData = []; + $params = $this->getRequestParams($commands); + + foreach ($commands as $alias) { + if (isset($params[$alias])) { + $formData[$alias] = $params[$alias]; + $this->installer->setRunOnlyIfRequired(true); + } elseif (isset($params['skip-' . $alias])) { + $formData['skip-' . $alias] = $params['skip-' . $alias]; + } + } + + $fields = $this->installer->getFormConfig($packages); + foreach ($fields as $name => $config) { + if (isset($params[$name])) { + $formData[$name] = $params[$name]; + } else { + $formData[$name] = $this->ask($config['title'], $config['options'] ?? null); + } + } + + $this->installer->setLogger($this->logger); + $this->installer->run($packages, array_merge($formData, [ + 'store_id' => $storeIds, + 'packages' => $packages, + ])); + + $output->writeln('Done.'); + + return \Magento\Framework\Console\Cli::RETURN_SUCCESS; + } + + /** + * Packages that are not registered in the codebase as a module or a theme + * + * @param array $packages + * @return array + */ + private function getMissingPackages(array $packages) + { + if ($this->input->getOption(self::INPUT_KEY_NO_DOWNLOAD)) { + return []; + } + + return array_filter($packages, function ($package) { + $moduleName = $this->componentHelper->convertPackageNameToModuleName($package); + + return !$this->componentRegistrar->getPath(ComponentRegistrar::MODULE, $moduleName) + && !$this->themePackageInfo->getFullThemePath($package); + }); + } + + /** + * @param array $packages + * @return boolean + */ + private function confirmDownload(array $packages) + { + if (!$this->input->isInteractive()) { + return false; + } + + $question = new ConfirmationQuestion( + sprintf( + 'Package(s) are not downloaded yet: %s Download and run installer? [Y/n] ', + implode(' ', $packages) + ), + true + ); + + return $this->questionHelper->ask($this->input, $this->output, $question); + } + + /** + * Downloaded code is not registered in the running process, so the + * installer is started again in a child process. + * + * @param array $packages + * @return int + */ + private function download(array $packages) + { + $arrayInput = new ArrayInput([ + PackageAbstractCommand::INPUT_ARGUMENT_PACKAGES => array_values($packages), + ]); + $arrayInput->setInteractive($this->input->isInteractive()); + + $code = $this->getApplication() + ->find('swissup:package:require') + ->run($arrayInput, $this->output); + + if ($code !== \Magento\Framework\Console\Cli::RETURN_SUCCESS) { + return $code; + } + + return $this->process->run( + $this->getRerunCommand(), + $this->output, + $this->input->isInteractive() + ); + } + + /** + * @return array + */ + private function getRerunCommand() + { + $command = array_merge( + [BP . '/bin/magento', $this->getName()], + $this->getArguments(), + // stop the child from downloading again if the package is still missing + ['--' . self::INPUT_KEY_NO_DOWNLOAD] + ); + + foreach ($this->input->getOption(self::INPUT_KEY_STORE) as $store) { + $command[] = '--' . self::INPUT_KEY_STORE . '=' . $store; + } + + if ($this->input->getOption('commands')) { + $command[] = '--commands'; + } + + return $command; + } + + private function getStoreIds() + { + $input = $this->input->getOption(self::INPUT_KEY_STORE); + + if (!$input) { + $result = $this->askStoreIds(); + } else { + // fix for the case when user entered --store=1,2 instead of --store=1 --store=2 + $result = []; + foreach ($input as $ids) { + foreach (explode(',', $ids) as $id) { + $result[] = $id; + } + } + } + + return $result; + } + + private function askStoreIds() + { + $stores = $this->getStoreList(); + + $codes = $this->ask( + (string) __('Please, select a Store'), + $stores, + true + ); + + $ids = []; + $codeToId = array_flip($stores); + foreach ($codes as $code) { + $ids[] = $codeToId[$code]; + } + + return $ids; + } + + private function ask($title, $options = null, $multiple = false) + { + if (!is_array($options)) { + $question = $this->questionFactory->create([ + 'question' => $title . ': ', + ]); + + return $this->questionHelper->ask($this->input, $this->output, $question); + } + + $choices = is_array(current($options)) ? + $this->optionsToChoices($options) : $options; + + if (count($choices) > 1) { + $question = $this->choiceQuestionFactory->create([ + 'question' => $title, + 'choices' => $choices, + ]); + + if ($multiple) { + $question->setMultiselect(true); + } + + $answer = $this->questionHelper->ask($this->input, $this->output, $question); + } else { + $answer = key($choices); + } + + return $answer; + } + + private function optionsToChoices($options) + { + $choices = []; + foreach ($options as $option) { + $choices[$option['value']] = $option['label']; + } + return $choices; + } + + /** + * Get the list of the stores WITHOUT WHITESPACES! + * Symfony multiselect losing whitespaces. + * + * symfony/console/Question/ChoiceQuestion.php:133 and 140: + * $selectedChoices = str_replace(' ', '', $selected); + * $selectedChoices = explode(',', $selectedChoices); + */ + private function getStoreList() + { + $result = [ + '0' => (string) __('All'), + ]; + + foreach ($this->storeManager->getStores() as $id => $store) { + $result[(string)$id] = sprintf( + '%s.[%s]', + str_replace(' ', ' ', str_pad($store->getName(), 20, '.')), + $store->getCode() + ); + } + + return $result; + } +} diff --git a/Console/Command/Installer/PackageRemoveCommand.php b/Console/Command/Installer/PackageRemoveCommand.php new file mode 100644 index 0000000..43f81c6 --- /dev/null +++ b/Console/Command/Installer/PackageRemoveCommand.php @@ -0,0 +1,122 @@ +setName('swissup:package:remove') + ->setDescription('Remove SwissupLabs package(s) using composer and run setup:upgrade') + ->addArgument( + self::INPUT_ARGUMENT_PACKAGES, + InputArgument::IS_ARRAY | InputArgument::REQUIRED, + 'Package name(s): swissup/firecheckout' + ) + ->addOption( + self::INPUT_OPTION_DRY_RUN, + null, + InputOption::VALUE_NONE, + 'Show what would be removed without changing any files' + ); + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $isDryRun = (bool) $input->getOption(self::INPUT_OPTION_DRY_RUN); + + try { + $packages = $this->validatePackages( + $input->getArgument(self::INPUT_ARGUMENT_PACKAGES) + ); + + $this->validate([ + 'composer remove ' . implode(' ', $packages), + 'bin/magento setup:upgrade', + ], $isDryRun); + + if ($isDryRun) { + $args = array_merge($this->getRemoveArgs($packages), ['--dry-run']); + return $this->composer->run($args, $output, $input->isInteractive()) + ? Cli::RETURN_FAILURE + : Cli::RETURN_SUCCESS; + } + + return $this->runAndUpgrade($this->getRemoveArgs($packages), $input, $output); + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + return Cli::RETURN_FAILURE; + } + } + + /** + * @param array $packages + * @return array + */ + private function getRemoveArgs(array $packages) + { + $args = array_merge( + ['remove'], + $packages, + [ + // swissup packages are often root requirements (installed one by one) + '--update-with-all-dependencies', + '--no-progress', + ] + ); + + if (!$this->composer->isDevInstalled()) { + $args[] = '--update-no-dev'; + } + + return $args; + } + + /** + * Only swissup packages listed in composer.json can be removed. + * Version constraint is dropped: composer remove does not accept it. + * + * @param array $packages + * @return array Package names + * @throws \RuntimeException + */ + private function validatePackages(array $packages) + { + $required = array_change_key_case($this->composer->getRequirements()); + $names = []; + + foreach ($packages as $package) { + $name = $this->getPackageName($package); + + if (!str_starts_with($name, 'swissup/')) { + throw new \RuntimeException(sprintf( + 'Only swissup packages can be removed with this command. Run composer remove %s instead.', + $name + )); + } + + if ($name === 'swissup/module-core') { + throw new \RuntimeException( + 'This command is a part of swissup/module-core. Run composer remove swissup/module-core instead.' + ); + } + + if (!isset($required[$name])) { + throw new \RuntimeException(sprintf( + 'Package "%s" is not required in composer.json.', + $name + )); + } + + $names[] = $name; + } + + return $names; + } +} diff --git a/Console/Command/Installer/PackageRequireCommand.php b/Console/Command/Installer/PackageRequireCommand.php new file mode 100644 index 0000000..7897d54 --- /dev/null +++ b/Console/Command/Installer/PackageRequireCommand.php @@ -0,0 +1,115 @@ +setName('swissup:package:require') + ->setDescription('Download SwissupLabs package(s) using composer and run setup:upgrade') + ->addArgument( + self::INPUT_ARGUMENT_PACKAGES, + InputArgument::IS_ARRAY | InputArgument::REQUIRED, + 'Package name(s), optionally with version constraint: swissup/firecheckout:^2.0' + ) + ->addOption( + self::INPUT_OPTION_DRY_RUN, + null, + InputOption::VALUE_NONE, + 'Show what would be installed without changing any files' + ); + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $packages = $input->getArgument(self::INPUT_ARGUMENT_PACKAGES); + $isDryRun = (bool) $input->getOption(self::INPUT_OPTION_DRY_RUN); + + try { + $this->validate([ + 'composer require ' . implode(' ', $packages), + 'bin/magento setup:upgrade', + ], $isDryRun); + + if (!$this->ensureRepositoryEnabled($input, $output)) { + return Cli::RETURN_FAILURE; + } + + $this->validatePackages($packages); + + if ($isDryRun) { + $args = array_merge($this->getRequireArgs($packages), ['--dry-run']); + return $this->composer->run($args, $output, $input->isInteractive()) + ? Cli::RETURN_FAILURE + : Cli::RETURN_SUCCESS; + } + + return $this->runAndUpgrade($this->getRequireArgs($packages), $input, $output); + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + return Cli::RETURN_FAILURE; + } + } + + /** + * @param array $packages + * @return array + */ + private function getRequireArgs(array $packages) + { + $args = array_merge( + ['require'], + $packages, + [ + // swissup packages are often root requirements (installed one by one) + '--update-with-all-dependencies', + '--no-progress', + ] + ); + + if (!$this->composer->isDevInstalled()) { + $args[] = '--update-no-dev'; + } + + return $args; + } + + /** + * Check access key and packages availability before touching the store. + * Much faster than "composer show --available". + * + * @param array $packages + * @return void + * @throws \RuntimeException + */ + private function validatePackages(array $packages) + { + $credentials = $this->repository->getCredentials(); + if (!$credentials['username'] || !$credentials['password']) { + throw new \RuntimeException( + 'Access key is not found. Run bin/magento swissup:channel:enable first.' + ); + } + + $available = array_change_key_case( + $this->repository->getPackages($credentials['username'], $credentials['password']) + ); + + foreach ($packages as $package) { + $name = $this->getPackageName($package); + if (!isset($available[$name])) { + throw new \RuntimeException(sprintf( + 'Package "%s" is not found.', + $name + )); + } + } + } +} diff --git a/Console/Command/Installer/PackageUpdateCommand.php b/Console/Command/Installer/PackageUpdateCommand.php new file mode 100644 index 0000000..d2c219f --- /dev/null +++ b/Console/Command/Installer/PackageUpdateCommand.php @@ -0,0 +1,158 @@ +setName('swissup:package:update') + ->setDescription('Update SwissupLabs packages using composer and run setup:upgrade') + ->addArgument( + self::INPUT_ARGUMENT_PACKAGES, + InputArgument::IS_ARRAY, + 'Package name(s): swissup/firecheckout. All swissup packages are updated when omitted' + ) + ->addOption( + self::INPUT_OPTION_DRY_RUN, + null, + InputOption::VALUE_NONE, + 'Show what would be updated without changing any files' + ) + ->addOption( + self::INPUT_OPTION_WITH_DEPENDENCIES, + 'w', + InputOption::VALUE_NONE, + 'Update 3rd party dependencies as well. Use it when composer cannot resolve the update' + ); + parent::configure(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $isDryRun = (bool) $input->getOption(self::INPUT_OPTION_DRY_RUN); + + try { + $packages = $this->validatePackages( + $input->getArgument(self::INPUT_ARGUMENT_PACKAGES) + ); + + if (!$packages) { + $output->writeln('There are no swissup packages to update'); + return Cli::RETURN_SUCCESS; + } + + $args = $this->getUpdateArgs( + $packages, + (bool) $input->getOption(self::INPUT_OPTION_WITH_DEPENDENCIES) + ); + + $this->validate([ + 'composer ' . implode(' ', $args), + 'bin/magento setup:upgrade', + ], $isDryRun); + + if (!$this->ensureRepositoryEnabled($input, $output)) { + return Cli::RETURN_FAILURE; + } + + if ($isDryRun) { + return $this->composer->run(array_merge($args, ['--dry-run']), $output, $input->isInteractive()) + ? Cli::RETURN_FAILURE + : Cli::RETURN_SUCCESS; + } + + return $this->runAndUpgrade($args, $input, $output); + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + return Cli::RETURN_FAILURE; + } + } + + /** + * Packages are listed explicitly, so composer is allowed to update them + * even though they are root requirements. Everything else is kept locked: + * --with-dependencies would also update the whole dependency tree of the + * swissup packages (symfony, guzzle, etc). + * + * @param array $packages + * @param boolean $withDependencies + * @return array + */ + private function getUpdateArgs(array $packages, $withDependencies) + { + $args = array_merge(['update'], $packages, ['--no-progress']); + + if ($withDependencies) { + $args[] = '--with-dependencies'; + } + + if (!$this->composer->isDevInstalled()) { + $args[] = '--no-dev'; + } + + return $args; + } + + /** + * Only swissup packages listed in composer.json can be updated. + * Version constraint is dropped to keep composer.json untouched. + * + * @param array $packages + * @return array Package names, or the swissup pattern when nothing is requested + * @throws \RuntimeException + */ + private function validatePackages(array $packages) + { + $installed = array_change_key_case($this->getInstalledPackages()); + + if (!$packages) { + return $installed ? ['swissup/*'] : []; + } + + $names = []; + + foreach ($packages as $package) { + $name = $this->getPackageName($package); + + if (!str_starts_with($name, 'swissup/')) { + throw new \RuntimeException(sprintf( + 'Only swissup packages can be updated with this command. Run composer update %s instead.', + $name + )); + } + + if (!isset($installed[$name])) { + throw new \RuntimeException(sprintf( + 'Package "%s" is not required in composer.json.', + $name + )); + } + + $names[] = $name; + } + + return $names; + } + + /** + * @return array Package name => version constraint + */ + private function getInstalledPackages() + { + return array_filter( + $this->composer->getRequirements(), + function ($name) { + return str_starts_with(strtolower($name), 'swissup/'); + }, + ARRAY_FILTER_USE_KEY + ); + } +} diff --git a/Console/Command/ModuleCommand.php b/Console/Command/ModuleCommand.php index b365136..0debc75 100644 --- a/Console/Command/ModuleCommand.php +++ b/Console/Command/ModuleCommand.php @@ -26,20 +26,20 @@ class ModuleCommand extends Command /** * - * @var \Swissup\Core\Model\ModuleFactory + * @var \Magento\Framework\Module\PackageInfo */ - private $moduleFactory; + private $packageInfo; /** * Inject dependencies * * @param \Swissup\Core\Model\ComponentList\Loader $loader - * @param \Swissup\Core\Model\ModuleFactory $moduleFactory + * @param \Magento\Framework\Module\PackageInfo $packageInfo */ - public function __construct(Loader $loader, \Swissup\Core\Model\ModuleFactory $moduleFactory) + public function __construct(Loader $loader, \Magento\Framework\Module\PackageInfo $packageInfo) { $this->loader = $loader; - $this->moduleFactory = $moduleFactory; + $this->packageInfo = $packageInfo; parent::__construct(); } @@ -99,22 +99,26 @@ protected function execute(InputInterface $input, OutputInterface $output): int $items = $this->loader->getItems(); - $codes = array_column($items, 'code', 'name'); - $packages = array_keys($codes); - if (in_array('Swissup_' . $moduleCode, $codes)) { - $moduleCode = 'Swissup_' . $moduleCode; - } elseif (in_array('Swissup_' . ucfirst($moduleCode), $codes)) { - $moduleCode = 'Swissup_' . ucfirst($moduleCode); - } elseif (in_array('swissup/' . $moduleCode, $packages)) { - $moduleCode = 'swissup/' . $moduleCode; - } elseif (in_array('swissup/module-' . $moduleCode, $packages)) { - $moduleCode = 'swissup/module-' . $moduleCode; - } + // only the real components have a unique module code: a metapackage + // shares its code with the module it requires + $packages = array_column($this->loader->getModuleItems(), 'name', 'code'); + + $candidates = [ + $packages[$moduleCode] ?? null, + $packages['Swissup_' . $moduleCode] ?? null, + $packages['Swissup_' . ucfirst($moduleCode)] ?? null, + $moduleCode, + 'swissup/module-' . $moduleCode, + 'swissup/' . $moduleCode, + ]; - if (in_array($moduleCode, $packages)) { - $moduleCode = $codes[$moduleCode]; + foreach ($candidates as $candidate) { + if ($candidate !== null && isset($items[$candidate])) { + $moduleCode = $candidate; + break; + } } - // $output->writeln($moduleName); + if (!isset($items[$moduleCode])) { $output->writeln('Package[Module] ' . $moduleCode .' doesn\'t exist'); $output->writeln('Run : php bin/magento swissup:module:list'); @@ -145,15 +149,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - $moduleModel = $this->moduleFactory->create(); - $moduleModel->load($moduleCode); - - $identityKey = $moduleModel->getData('identity_key'); - if (!empty($identityKey)) { - $rows[] = ["Identity Key", $identityKey]; + try { + // array_filter to remove empty items caused by non-magento modules requirements + $depends = array_filter($this->packageInfo->getRequire($moduleCode)); + } catch (\Exception $e) { + $depends = []; } - - $depends = $moduleModel->getData('depends'); if (!empty($depends)) { $rows[] = ["Depends", implode(' ', $depends)]; } diff --git a/Console/Command/ModuleListCommand.php b/Console/Command/ModuleListCommand.php index c6c82ba..d0d3363 100644 --- a/Console/Command/ModuleListCommand.php +++ b/Console/Command/ModuleListCommand.php @@ -108,7 +108,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->loader->refresh(); } - $items = $this->loader->getItems(); + $items = $this->loader->getModuleItems(); $output->writeln('List of swissup modules : ' . count($items)); $rows = []; diff --git a/Installer/Command/CategoryUpdate.php b/Installer/Command/CategoryUpdate.php new file mode 100644 index 0000000..0c043a3 --- /dev/null +++ b/Installer/Command/CategoryUpdate.php @@ -0,0 +1,51 @@ +logger->info('Category Update: Prepare category data'); + + foreach ($request->getParams() as $data) { + $collection = $this->collectionHelper->getCollection( + [], + \Magento\Catalog\Model\ResourceModel\Category\Collection::class, + $data['filters'] ?? [] + ); + + $storeIds = $data['store_id'] ?? [Store::DEFAULT_STORE_ID]; + if (!is_array($storeIds)) { + $storeIds = [$storeIds]; + } + + foreach ($collection as $category) { + foreach ($data['data'] as $key => $value) { + $category + ->setData($key, $value) + ->setCustomAttribute($key, $value); + } + + foreach ($storeIds as $storeId) { + try { + $category->setStoreId($storeId)->save(); + } catch (\Exception $e) { + $this->logger->warning($e->getMessage()); + } + } + } + } + } +} diff --git a/Installer/Command/CmsBlock.php b/Installer/Command/CmsBlock.php new file mode 100644 index 0000000..e0d6374 --- /dev/null +++ b/Installer/Command/CmsBlock.php @@ -0,0 +1,82 @@ +logger->info('Cms Blocks: Backup existing and create new blocks'); + + $idsToInstall = array_flip($request->getExtraOptions()); + $isSingleStoreMode = $this->storeManager->isSingleStoreMode(); + + foreach ($request->getParams() as $data) { + if ($idsToInstall && !isset($idsToInstall[$data['identifier']])) { + continue; + } + + $collection = $this->collectionFactory->create() + ->addStoreFilter($request->getStoreIds()) + ->addFieldToFilter('identifier', $data['identifier']); + + foreach ($collection as $block) { + $block->load($block->getId()); // load stores + + $storesToLeave = array_diff($block->getStoreId(), $request->getStoreIds()); + + if (count($storesToLeave) && !$isSingleStoreMode) { + $block->setStores($storesToLeave); + } else { + $block->setIsActive(0) + ->setIdentifier($this->getBackupIdentifier($block->getIdentifier())); + } + + try { + $block->save(); + } catch (\Exception $e) { + $this->logger->warning($e->getMessage()); + } + } + + $data = array_merge([ + 'is_active' => 1, + ], $data); + + try { + $this->blockFactory->create() + ->setData($data) + ->setStores($request->getStoreIds()) // see Magento\Cms\Model\ResourceModel\Block::_afterSave + ->save(); + } catch (\Exception $e) { + $this->logger->warning($e->getMessage()); + } + } + } + + /** + * @param string $identifier + * @return string + */ + private function getBackupIdentifier($identifier) + { + return $identifier + . '_backup_' + . rand(10, 99) + . '_' + . $this->localeDate->date()->format('Y-m-d-H-i-s'); + } +} diff --git a/Installer/Command/CmsPage.php b/Installer/Command/CmsPage.php new file mode 100644 index 0000000..9a4aeda --- /dev/null +++ b/Installer/Command/CmsPage.php @@ -0,0 +1,149 @@ +getExtraOptions()); + $identifiers = array_map( + function ($item) { + return $item['identifier']; + }, + $request->getParams() + ); + + $this->cleanupUrlRewrites($identifiers, $request->getStoreIds()); + + $isSingleStoreMode = $this->storeManager->isSingleStoreMode(); + $collection = $this->collectionFactory->create() + ->addStoreFilter($request->getStoreIds()) + ->addFieldToFilter('is_active', 1) + ->addFieldToFilter('identifier', ['in' => $identifiers]); + + $this->logger->info('Cms Pages: Backup existing pages'); + foreach ($collection as $page) { + if ($idsToInstall && !isset($idsToInstall[$page->getIdentifier()])) { + continue; + } + + $page->load($page->getId()); // load stores + + $storesToLeave = array_diff($page->getStoreId(), $request->getStoreIds()); + + if (count($storesToLeave) && !$isSingleStoreMode) { + $page->setStores($storesToLeave); + } else { + // duplicate page, because original page will be used for new content + $page = $this->pageFactory->create() + ->addData($page->getData()) + ->unsPageId() + ->setIsActive(0) + ->setIdentifier($this->getBackupIdentifier($page->getIdentifier())); + } + + try { + $page->save(); + } catch (\Exception $e) { + $this->logger->warning( + sprintf('%s "%s"', $e->getMessage(), $page->getIdentifier()) + ); + } + } + + $this->logger->info('CMS PAGES: Create new pages'); + foreach ($request->getParams() as $data) { + if ($idsToInstall && !isset($idsToInstall[$data['identifier']])) { + continue; + } + + $canUseExistingPage = false; + $pages = $collection->getItemsByColumnValue( + 'identifier', + $data['identifier'] + ); + + // If page is linked to destination stores only - use it. Otherwise, create new. + foreach ($pages as $page) { + $diff = array_diff($page->getStoreId(), $request->getStoreIds()); + if (!count($diff)) { + $canUseExistingPage = true; + break; + } + } + + if (!$canUseExistingPage) { + $page = $this->pageFactory->create(); + } + + $data = array_merge([ + 'is_active' => 1, + 'page_layout' => '1column', + 'content_heading' => '', + 'layout_update_xml' => '', + 'custom_theme' => null, + 'custom_root_template' => null, + 'custom_layout_update_xml' => null, + ], $data); + + try { + $page->addData($data) + ->setStores($request->getStoreIds()) // see Magento\Cms\Model\ResourceModel\Page::_afterSave + ->save(); + } catch (\Exception $e) { + $this->logger->warning( + sprintf('%s "%s"', $e->getMessage(), $data['identifier']) + ); + } + } + } + + /** + * @param string $identifier + * @return string + */ + private function getBackupIdentifier($identifier) + { + return $identifier + . '_backup_' + . rand(10, 99) + . '_' + . $this->localeDate->date()->format('Y-m-d-H-i-s'); + } + + /** + * @param array $identifiers + * @return void + */ + private function cleanupUrlRewrites($identifiers, $storeIds) + { + $urls = $this->urlRewriteCollectionFactory->create() + ->addFieldToFilter('entity_type', 'cms-page') + ->addFieldToFilter('request_path', ['in' => $identifiers]); + + if (in_array(0, $storeIds)) { + $storeIds = array_keys($this->storeManager->getStores(true)); + } + + $urls->addFieldToFilter('store_id', ['in' => $storeIds]); + + foreach ($urls as $url) { + $url->delete(); + } + } +} diff --git a/Installer/Command/Config.php b/Installer/Command/Config.php new file mode 100644 index 0000000..0c77557 --- /dev/null +++ b/Installer/Command/Config.php @@ -0,0 +1,89 @@ +logger->info('Config: Update store parameters'); + + foreach ($request->getStoreIds() as $storeId) { + foreach ($request->getParams() as $path => $value) { + if (is_array($value)) { + $this->processArray($path, $value, $storeId); + } else { + $this->processScalar($path, $value, $storeId); + } + } + } + } + + /** + * @param string $path + * @param string $value + * @param int $storeId + * @return void + */ + private function processScalar($path, $value, $storeId) + { + if (!$storeId) { + $scope = ScopeConfigInterface::SCOPE_TYPE_DEFAULT; + } else { + $scope = ScopeInterface::SCOPE_STORES; + } + + $this->configWriter->save($path, $value, $scope, $storeId); + } + + /** + * @param array $path + * @param array $data + * @param int $storeId + * @return void + */ + private function processArray($path, $data, $storeId) + { + if (!$storeId) { + $scope = ScopeConfigInterface::SCOPE_TYPE_DEFAULT; + } else { + $scope = ScopeInterface::SCOPE_STORE; + } + + $remote = $data['path'] ?? $path; + $search = $data['search'] ?? $data['remove']; + $replace = $data['replace'] ?? ''; + $value = $this->scopeConfig->getValue($remote, $scope, $storeId); + + if (!$value) { + return; + } + + if (!is_array($search)) { + $search = [$search]; + } + + foreach ($search as $i => $string) { + $value = str_replace( + $string, + is_array($replace) ? ($replace[$i] ?? '') : $replace, + $value + ); + } + + $this->processScalar($path, $value, $storeId); + } +} diff --git a/Installer/Command/CopyMediaDir.php b/Installer/Command/CopyMediaDir.php new file mode 100644 index 0000000..c425b19 --- /dev/null +++ b/Installer/Command/CopyMediaDir.php @@ -0,0 +1,49 @@ +logger->info('Resources: Copy media files'); + + $media = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA); + $driver = $media->getDriver(); + $mediaPath = $media->getAbsolutePath(); + + foreach ($request->getParams() as $dir) { + $paths = $driver->readDirectoryRecursively($dir); + $paths = array_reverse($paths); // put deepest in the end + + foreach ($paths as $path) { + $relative = str_replace($dir . '/', '', $path); + $destination = $mediaPath . $relative; + + try { + if ($driver->isExists($destination)) { + continue; + } + + if ($driver->isDirectory($path)) { + $driver->createDirectory($destination); + } else { + $driver->copy($path, $destination); + } + } catch (\Exception $e) { + $this->logger->warning($e->getMessage()); + } + } + } + } +} diff --git a/Installer/Command/Product.php b/Installer/Command/Product.php new file mode 100644 index 0000000..f538d87 --- /dev/null +++ b/Installer/Command/Product.php @@ -0,0 +1,14 @@ +logger->warning('Product Command is deprecated. Please use ProductCollection instead'); + parent::execute($request); + } +} diff --git a/Installer/Command/ProductAttribute.php b/Installer/Command/ProductAttribute.php new file mode 100644 index 0000000..9d8d132 --- /dev/null +++ b/Installer/Command/ProductAttribute.php @@ -0,0 +1,78 @@ +logger->info('Product Attributes: Update attributes'); + + $entityTypeId = $this->eavEntityFactory->create() + ->setType(\Magento\Catalog\Model\Product::ENTITY) + ->getTypeId(); + $attributeSets = $this->attributeSetCollectionFactory->create() + ->setEntityTypeFilter($entityTypeId); + + foreach ($request->getParams() as $data) { + /* @var $model \Magento\Catalog\Model\ResourceModel\Eav\Attribute */ + $model = $this->attributeFactory->create() + ->load($data['attribute_code'], 'attribute_code'); + if ($model->getId()) { + continue; + } + + $data = array_merge([ + 'is_global'=> 0, + 'frontend_input'=> 'boolean', + 'is_configurable'=> 0, + 'is_filterable'=> 0, + 'is_filterable_in_search' => 0, + 'sort_order' => 1, + ], $data); + + $data['source_model'] = $this->productHelper->getAttributeSourceModelByInputType( + $data['frontend_input'] + ); + $data['backend_model'] = $this->productHelper->getAttributeBackendModelByInputType( + $data['frontend_input'] + ); + $data['backend_type'] = $model->getBackendTypeByInput($data['frontend_input']); + + $model->addData($data); + $model->setEntityTypeId($entityTypeId); + $model->setIsUserDefined(1); + + foreach ($attributeSets as $set) { + $model->setAttributeSetId($set->getId()); + $model->setAttributeGroupId($set->getDefaultGroupId()); + try { + $model->save(); + } catch (\Exception $e) { + $this->logger->warning($e->getMessage()); + } + } + + if (!$attributeSets->count()) { + try { + $model->save(); + } catch (\Exception $e) { + $this->logger->warning($e->getMessage()); + } + } + } + } +} diff --git a/Installer/Command/ProductCollection.php b/Installer/Command/ProductCollection.php new file mode 100644 index 0000000..079b145 --- /dev/null +++ b/Installer/Command/ProductCollection.php @@ -0,0 +1,98 @@ +logger->info('Product Collection: Prepare collections'); + + $data = $request->getParams(); + $visibility = $this->catalogProductVisibility->getVisibleInCatalogIds(); + $isAllStores = in_array(0, $request->getStoreIds()); + $attributes = $this->attributeCollectionFactory->create() + ->addFieldToFilter('attribute_code', ['in' => array_keys($data)]); + + foreach ($attributes as $attribute) { + $collection = $this->productCollectionFactory->create() + ->setPageSize(1) + ->setCurPage(1); + + switch ($attribute->getFrontendInput()) { + case 'boolean': + $value = 1; + $unsetValue = 0; + $collection->addAttributeToFilter($attribute, 1); + break; + case 'date': + $value = $this->localeDate->date()->format('Y-m-d H:i:s'); + // far future date never matches the 'to now' filter + $unsetValue = '2222-12-31 00:00:00'; + $collection->addAttributeToFilter( + $attribute, + [ + [ + 'date' => true, + 'to' => $value + ] + ] + ); + break; + default: + continue 2; + } + + if ($collection->getSize()) { + // store has products with specified attribute + continue; + } + + foreach ($request->getStoreIds() as $storeId) { + $collectionStoreId = $storeId; + + if ($storeId == \Magento\Store\Model\Store::DEFAULT_STORE_ID) { + // compatibility with M2.2.5 when install on 'All Store Views' + $collectionStoreId = $this->storeManager->getDefaultStoreView()->getId(); + } + + $visibleProducts = $this->productCollectionFactory->create() + ->setStoreId($collectionStoreId) + ->setVisibility($visibility) + ->addStoreFilter($storeId) + ->setPageSize($data[$attribute->getAttributeCode()]) + ->setCurPage(1); + + if (!$visibleProducts->getSize()) { + continue; + } + + foreach ($visibleProducts as $product) { + $product->addAttributeUpdate( + $attribute->getAttributeCode(), + $isAllStores ? $value : $unsetValue, + \Magento\Store\Model\Store::DEFAULT_STORE_ID + ); + + $product->setStoreId($storeId) + ->setData($attribute->getAttributeCode(), $value) + ->save(); + } + } + } + } +} diff --git a/Installer/Command/Unpack.php b/Installer/Command/Unpack.php new file mode 100644 index 0000000..8973012 --- /dev/null +++ b/Installer/Command/Unpack.php @@ -0,0 +1,29 @@ +logger->info('Unpack'); + $params = $request->getParams(); + $destination = $params['destination']; + $this->ioFile->checkAndCreateFolder($destination); + $archive = $params['archive']; + $this->archiver->unpack($archive, $destination); + } +} diff --git a/Installer/Command/Widget.php b/Installer/Command/Widget.php new file mode 100644 index 0000000..1d99d53 --- /dev/null +++ b/Installer/Command/Widget.php @@ -0,0 +1,140 @@ +logger->info('Widgets: Backup existing and create new widgets'); + + $isSingleStoreMode = $this->storeManager->isSingleStoreMode(); + + foreach ($request->getParams() as $raw) { + $collection = $this->collectionFactory->create() + ->addStoreFilter($request->getStoreIds()) + ->addFieldToFilter('title', $raw['title']) + ->addFieldToFilter('instance_type', $raw['type']) + ->addFieldToFilter('theme_id', $raw['theme_id']); + + foreach ($collection as $widget) { + $storesToLeave = array_diff($widget->getStoreIds(), $request->getStoreIds()); + + if (count($storesToLeave) && !$isSingleStoreMode) { + // unset stores. new widget will be added for them. + $widget->setStoreIds($storesToLeave); + } else { + // re-create widget with new params + $widget->delete(); + continue; + } + + try { + $widget->save(); + } catch (\Exception $e) { + $this->logger->warning($e->getMessage()); + } + } + + $data = [ + 'title' => $raw['title'], + 'instance_type' => $raw['type'], + 'theme_id' => $raw['theme_id'], + 'store_ids' => $request->getStoreIds(), + 'widget_parameters' => $raw['params'], + 'sort_order' => $raw['sort_order'] ?? 0, + ]; + + $pageGroups = []; + foreach ($raw['pages'] as $page) { + $pageGroup = $this->getPageGroupData($page, [ + 'page_id' => 0, + 'for' => 'all', + 'block' => $page['reference'] ?? 'content.top', + 'template' => $raw['template'] ?? '', + ]); + + if ($pageGroup) { + $pageGroups[] = $pageGroup; + } + } + + $data['page_groups'] = $pageGroups; + + try { + $this->widgetFactory->create()->addData($data)->save(); + } catch (\Exception $e) { + $this->logger->warning($e->getMessage()); + } + } + } + + private function getPageGroupData($data, $defaultData) + { + $groupName = $groupData = null; + + if (isset($data['handle'])) { + $groupName = $this->getGroupName($data['handle']); + $groupData = [ + 'layout_handle' => $data['handle'], + ]; + } elseif (isset($data['page_layout'])) { + $groupName = 'page_layouts'; + $groupData = [ + 'layout_handle' => $data['page_layout'], + ]; + } elseif (isset($data['category_ids'])) { + $groupName = 'anchor_categories'; + $groupData = [ + 'for' => Instance::SPECIFIC_ENTITIES, + 'entities' => $data['category_ids'], + ]; + } elseif (isset($data['product_ids'])) { + $groupName = 'all_products'; + $groupData = [ + 'for' => Instance::SPECIFIC_ENTITIES, + 'entities' => $data['product_ids'], + ]; + } + + if ($groupName && $groupData) { + return [ + 'page_group' => $groupName, + $groupName => array_merge($defaultData, $groupData), + ]; + } + + return false; + } + + private function getGroupName($handle) + { + $mapping = [ + 'default' => 'all_pages', + 'catalog_product_view' => 'all_products', + 'catalog_product_view_type_simple' => 'simple_products', + 'catalog_product_view_type_virtual' => 'virtual_products', + 'catalog_product_view_type_bundle' => 'bundle_products', + 'catalog_product_view_type_configurable' => 'configurable_products', + 'catalog_product_view_type_downloadable' => 'downloadable_products', + 'catalog_product_view_type_grouped' => 'grouped_products', + 'catalog_category_view_type_layered' => 'anchor_categories', + 'catalog_category_view_type_default' => 'notanchor_categories', + ]; + + return $mapping[$handle] ?? 'pages'; + } +} diff --git a/Installer/ConfigReader.php b/Installer/ConfigReader.php new file mode 100644 index 0000000..e834f15 --- /dev/null +++ b/Installer/ConfigReader.php @@ -0,0 +1,412 @@ +readFiles() as $path => $content) { + $xml = $this->configFactory->create(['sourceData' => $content]); + $nodePackages = (array) $xml->getNode('packages/package'); + + if (array_intersect($packages, $nodePackages)) { + return true; + } + } + + return false; + } + + /** + * @return array + * @throws \Magento\Framework\Exception\LocalizedException + */ + public function read() + { + $output = [ + 'rules' => [], // package/name => [installer_keys] + 'fields' => [], // installer_key => form data + 'conditions' => [], // installer_key => conditions to check before commands execution + 'commands' => [], // installer_key => commands to run + ]; + + foreach ($this->readFiles() as $path => $content) { + $this->currentPath = $path; + + $xml = $this->configFactory->create(['sourceData' => $content]); + + $packages = (array) $xml->getNode('packages/package'); + foreach ($packages as $packageName) { + $output['rules'][$packageName][] = $path; + } + + $output['fields'][$path] = $this->parseFields($xml); + $output['conditions'][$path] = $this->parseConditions($xml); + $output['commands'][$path] = $this->parseCommands($xml); + } + + return $output; + } + + /** + * @return array + */ + protected function readFiles() + { + if ($this->files !== null) { + return $this->files; + } + + $this->files = []; + $paths = $this->componentRegistrar->getPaths(ComponentRegistrar::MODULE); + $paths += $this->componentRegistrar->getPaths(ComponentRegistrar::THEME); + + foreach ($paths as $path) { + $dir = $this->readDirFactory->create($path); + $filepath = self::DIR . '/' . self::FILE; + + if (!$dir->isReadable($filepath)) { + continue; + } + + $this->files[$path] = $dir->readFile($filepath); + } + + return $this->files; + } + + /** + * @param Config $xml + * @return array + */ + protected function parseFields(Config $xml) + { + $node = $xml->getNode('fields'); + if (!$node) { + return []; + } + + $result = []; + foreach ($node->children() as $field) { + $name = $field->getAttribute('name'); + + if (!isset($result[$name])) { + $result[$name] = []; + } + + $result[$name]['title'] = $field->getAttribute('title'); + + if (!$field->hasChildren()) { + continue; + } + + $options = false; + $items = $field->descend('option'); + $model = $field->descend('source_model'); + + if ($items) { + $options = []; + foreach ($items as $item) { + $value = (string) $item[0]; + $options[$value] = [ + 'value' => $value, + 'label' => $item->getAttribute('title'), + ]; + } + } elseif ($model) { + $model = (string) $model[0]; + $options = $this->objectManager->get($model)->toOptionArray(); + } + + if ($options !== false) { + $result[$name]['options'] = $options; + } + } + + return $result; + } + + /** + * @param Config $xml + * @return array + */ + protected function parseConditions(Config $xml) + { + $node = $xml->getNode('commands'); + if (!$node || !$node->descend('conditions')) { + return []; + } + + $result = []; + foreach ($node->descend('conditions') as $condition) { + $result = $this->parseArguments($condition); + } + + return $result; + } + + /** + * @param Config $xml + * @return array + */ + protected function parseCommands(Config $xml) + { + $node = $xml->getNode('commands'); + if (!$node) { + return []; + } + + $commands = []; + foreach ($node->children() as $child) { + $tagName = $child->getName(); + + if (!in_array($tagName, ['command', 'include'])) { + continue; + } + + if ($tagName === 'command') { + $commands[] = $child; + continue; + } + + // read commands from separate file + $path = $child->getAttribute('path'); + $dir = $this->readDirFactory->create($this->currentPath . '/' . self::DIR); + + if (!$dir->isReadable($path)) { + continue; + } + + $xml = $this->configFactory->create([ + 'sourceData' => $dir->readFile($path) + ]); + + foreach ($xml->getNode('command') as $command) { + $commands[] = $command; + } + } + + $result = []; + foreach ($commands as $i => $command) { + $class = $this->resolveClass($command->getAttribute('class')); + $module = $command->getAttribute('module'); + + if (!$this->isModuleEnabled($module) || !class_exists($class)) { + continue; + } + + $result[$i]['class'] = $class; + + if (defined($class . '::ALIAS')) { + $alias = constant($class . '::ALIAS'); + } else { + // convert to kebabcase https://stackoverflow.com/a/75687338/2754377 + $classparts = explode('\\', $class); + $alias = end($classparts); + $alias = strtolower(preg_replace('/(?:\d++|[A-Za-z]?[a-z]++)\K(?!$)/', '-', $alias)); + } + + $result[$i]['alias'] = $alias; + + if (!$command->hasChildren() || !$command->descend('data')) { + continue; + } + + $result[$i]['data'] = $this->parseArguments( + $command->descend('data')->children() + ); + } + + return $result; + } + + /** + * Installer files of the released packages reference the classes of + * swissup/module-marketplace, which is not required anymore. + * + * @param string $class Class name, optionally with ::method suffix + * @return string + */ + protected function resolveClass($class) + { + list($name, $method) = array_pad(explode('::', $class, 2), 2, null); + + $ported = str_replace( + 'Swissup\\Marketplace\\Installer\\', + 'Swissup\\Core\\Installer\\', + $name + ); + + if (class_exists($ported)) { + $name = $ported; + } + + return $method === null ? $name : $name . '::' . $method; + } + + /** + * @param string $module + * @return boolean + */ + protected function isModuleEnabled($module) + { + if (!$module) { + return true; + } + return $this->moduleManager->isEnabled($module); + } + + /** + * @param \Magento\Framework\Simplexml\Element $node + * @return array + * @throws \Exception + */ + protected function parseArguments(\Magento\Framework\Simplexml\Element $node) + { + $i = 0; + $result = []; + + foreach ($node as $item) { + $key = $item->getAttribute('name') ?: $i++; + $helper = $item->getAttribute('helper'); + + if ($helper) { + $helper = $this->resolveClass($helper); + } + + if (!$item->hasChildren() && !$helper) { + $value = (string) $item[0]; + $type = (string) $item->getAttribute('type'); + + if ($type) { + $method = 'prepare' . ucfirst($type); + if (method_exists($this, $method)) { + $value = $this->{$method}($value); + } + } + + $result[$key] = $value; + continue; + } + + $arguments = $this->parseArguments($item->children()); + + if ($helper) { + $result[$key] = [ + 'helper' => $helper, + 'arguments' => $arguments, + ]; + } else { + $result[$key] = $arguments; + } + } + + return $result; + } + + /** + * @param string $value + * @return string + * @throws SecurityViolationException + */ + private function preparePath($value) + { + $subdir = $this->currentPath . '/' . self::DIR . '/'; + $subdir = str_replace('/', DIRECTORY_SEPARATOR, $subdir); + $result = $subdir . $value; + $result = realpath($result); + + if ($result === false || strpos($result, $subdir) !== 0) { + throw new SecurityViolationException( + __( + 'Error during "%1" processing. Relative paths are forbidden: "%2"', + $this->currentPath, + $value + ) + ); + } + + return $result; + } + + /** + * @param string $value + * @return mixed + * @throws RuntimeException + */ + private function prepareConst($value) + { + if (!defined($value)) { + throw new RuntimeException( + __('Requested constant is not defined: %1', $value) + ); + } + return constant($value); + } + + /** + * @param string $value + * @return int + */ + private function prepareInt($value) + { + return (int) $value; + } + + /** + * @param string $value + * @return boolean + */ + private function prepareBoolean($value) + { + if ($value === 'false') { + $value = false; + } else { + $value = (bool) $value; + } + return $value; + } + + /** + * @param string $value + * @return boolean + */ + private function prepareNull($value) + { + return null; + } +} diff --git a/Installer/Helper/Arr.php b/Installer/Helper/Arr.php new file mode 100644 index 0000000..9529674 --- /dev/null +++ b/Installer/Helper/Arr.php @@ -0,0 +1,11 @@ +objectManager->create($class); + + foreach ($filters as $filter) { + if (isset($filter['field'], $filter['value'])) { + $collection->addFieldToFilter($filter['field'], $filter['value']); + } elseif (isset($filter['method'], $filter['params'])) { + call_user_func_array([$collection, $filter['method']], $filter['params']); + } + } + + return $collection; + } + + public function getCollection(array $request, $class, array $filters = []) + { + return $this->prepareCollection($class, $filters); + } + + /** + * @param array $request + * @param string $class + * @param array $filters + * @return int|string + */ + public function getId(array $request, $class, array $filters = []) + { + return $this->prepareCollection($class, $filters)->setPageSize(1)->getFirstItem()->getId(); + } + + /** + * @param array $request + * @param string $class + * @param array $filters + * @return array + */ + public function getIds(array $request, $class, array $filters = []) + { + return $this->prepareCollection($class, $filters)->getAllIds(); + } +} diff --git a/Installer/Helper/Renderer.php b/Installer/Helper/Renderer.php new file mode 100644 index 0000000..f389837 --- /dev/null +++ b/Installer/Helper/Renderer.php @@ -0,0 +1,25 @@ +jsonSerializer->serialize($value); + } +} diff --git a/Installer/Helper/Text.php b/Installer/Helper/Text.php new file mode 100644 index 0000000..26cfabf --- /dev/null +++ b/Installer/Helper/Text.php @@ -0,0 +1,15 @@ +memo[$path])) { + $this->memo[$path] = $this->collectionFactory->create() + ->getThemeByFullPath($path) + ->getThemeId(); + } + return $this->memo[$path]; + } +} diff --git a/Installer/Installer.php b/Installer/Installer.php new file mode 100644 index 0000000..36967de --- /dev/null +++ b/Installer/Installer.php @@ -0,0 +1,235 @@ +runOnlyIfRequired = $flag; + return $this; + } + + /** + * Run packages installation with optional request data. + * + * @param array $packages + * @param array $requestData + * @return void + */ + public function run(array $packages, array $requestData = []) + { + $request = $this->requestFactory->create(['data' => $requestData]); + + try { + $this->appState->getAreaCode(); + } catch (\Exception $e) { + $this->appState->setAreaCode(\Magento\Framework\App\Area::AREA_ADMINHTML); + } + + foreach ($this->getInstallers($packages) as $installer) { + $requirements = $this->data['conditions'][$installer] ?? []; + foreach ($requirements as $param => $value) { + if ($request->getData($param) != $value) { + // customer didn't select this package in the form. + // Another argento theme, for example. + continue 2; + } + } + + $info = array_slice(explode('/', $installer), -2, 2); + $this->getLogger()->notice(sprintf('Processing %s', implode('/', $info))); + + $commands = $this->data['commands'][$installer] ?? []; + + foreach ($commands as $config) { + if (isset($requestData['skip-' . $config['alias']]) || + $this->runOnlyIfRequired && !isset($requestData[$config['alias']]) + ) { + continue; + } + + $request->setExtraOptions([]); + if (!empty($requestData[$config['alias']])) { + $request->setExtraOptions(explode(',', $requestData[$config['alias']])); + } + + $params = []; + $data = isset($config['data']) ? $config['data'] : []; + foreach ($data as $key => $param) { + $params[$key] = $this->processArguments($param, $requestData); + } + + $request->setParams($params); + + $command = $this->objectManager->get($config['class']); + + if (method_exists($command, 'setLogger')) { + $command->setLogger($this->getLogger()); + } + + $command->execute($request); + } + } + + $this->cache->clean(array_intersect([ + 'block_html', + 'config', + 'full_page', + 'layout', + 'translate', + 'compiled_config', + ], $this->cache->getAvailableTypes())); + } + + /** + * @param mixed $packages + * @return boolean + */ + public function hasInstaller($packages) + { + if (!is_array($packages)) { + $packages = [$packages]; + } + + return $this->configReader->hasConfig($packages); + } + + /** + * @param array $packages + * @return array + */ + public function getFormConfig($packages) + { + $result = []; + + foreach ($this->getInstallers($packages) as $installer) { + $fields = $this->data['fields'][$installer] ?? []; + $result = array_replace_recursive($result, $fields); + } + + return $result; + } + + public function getCommandAliases($packages) + { + $result = []; + + foreach ($this->getInstallers($packages) as $installer) { + foreach ($this->data['commands'][$installer] as $command) { + if (isset($command['alias'])) { + $result[] = $command['alias']; + } + } + } + + return $result; + } + + /** + * @param string $package + * @return array + */ + private function getLogger(): LoggerInterface + { + return $this->logger ??= new NullLogger(); + } + + /** + * @param string $package + * @return array + */ + private function getInstaller($package) + { + $this->load(); + + return $this->data['rules'][$package] ?? []; + } + + /** + * @param array $packages + * @return array + */ + private function getInstallers($packages) + { + $result = []; + + foreach ($packages as $package) { + $installer = $this->getInstaller($package); + + if (!$installer) { + continue; + } + + $result = array_merge($result, $installer); + } + + return array_unique($result); + } + + /** + * @param array $arguments + * @param array $requestData + * @return array + */ + private function processArguments($arguments, $requestData) + { + if (!is_array($arguments)) { + return $arguments; + } + + // start from the deepest nested helper + foreach ($arguments as $key => $value) { + if (is_array($value)) { + $arguments[$key] = $this->processArguments($value, $requestData); + } + } + + if (isset($arguments['helper'], $arguments['arguments']) && + strpos($arguments['helper'], '::') !== false + ) { + list($class, $method) = explode('::', $arguments['helper']); + + return call_user_func_array( + [$this->objectManager->get($class), $method], + array_merge([$requestData], array_values($arguments['arguments'])) + ); + } + + return $arguments; + } + + /** + * @return void + */ + private function load() + { + if ($this->data !== null) { + return; + } + + $this->data = $this->configReader->read(); + } +} diff --git a/Installer/Request.php b/Installer/Request.php new file mode 100644 index 0000000..0eb4012 --- /dev/null +++ b/Installer/Request.php @@ -0,0 +1,13 @@ +_data['store_id'] ?? [Store::DEFAULT_STORE_ID]; + } +} diff --git a/Model/ComponentList/Loader.php b/Model/ComponentList/Loader.php index 1866971..2e47d8f 100644 --- a/Model/ComponentList/Loader.php +++ b/Model/ComponentList/Loader.php @@ -101,6 +101,13 @@ public function getItems() return $this->load(); } + public function getModuleItems() + { + return array_filter($this->getItems(), function ($item) { + return ($item['type'] ?? '') !== 'metapackage'; + }); + } + public function getInstalledItems() { return array_filter($this->getItems(), function ($item) { diff --git a/Model/ComponentList/Loader/AbstractLoader.php b/Model/ComponentList/Loader/AbstractLoader.php index d023683..e4544be 100644 --- a/Model/ComponentList/Loader/AbstractLoader.php +++ b/Model/ComponentList/Loader/AbstractLoader.php @@ -57,13 +57,12 @@ public function load() } foreach ($components as $name => $config) { - $code = $this->componentHelper->convertPackageNameToModuleName( - $config['name'] - ); + $id = $config['name']; + $this->items[$id]['code'] = $this->componentHelper + ->convertPackageNameToModuleName($id); - $this->items[$code]['code'] = $code; foreach ($this->getMapping() as $source => $destination) { - if (!empty($this->items[$code][$destination])) { + if (!empty($this->items[$id][$destination])) { continue; } @@ -78,7 +77,7 @@ public function load() if (is_array($value)) { $value = implode(',', $value); } - $this->items[$code][$destination] = $value; + $this->items[$id][$destination] = $value; } } return $this->items; diff --git a/Model/ComponentList/Loader/Remote.php b/Model/ComponentList/Loader/Remote.php index fcb42de..623288a 100644 --- a/Model/ComponentList/Loader/Remote.php +++ b/Model/ComponentList/Loader/Remote.php @@ -60,8 +60,6 @@ public function getMapping() 'version' => 'latest_version', 'type' => 'type', 'time' => 'release_date', - 'extra.marketplace.links.docs' => 'docs_link', - 'extra.marketplace.links.changelog' => 'changelog_link', 'extra.swissup.links.store' => 'link', 'extra.swissup.links.docs' => 'docs_link', 'extra.swissup.links.download' => 'download_link', @@ -69,6 +67,8 @@ public function getMapping() 'extra.swissup.links.marketplace' => 'marketplace_link', 'extra.swissup.links.identity_key' => 'identity_key_link', 'extra.swissup.purchase_code' => 'purchase_code', + 'extra.marketplace.links.docs' => 'docs_link', + 'extra.marketplace.links.changelog' => 'changelog_link', ]; } @@ -92,12 +92,6 @@ public function getComponentsInfo() return $carry; }, $versions[0] ?? 0); - if (!empty($info[$latestVersion]['type']) && - $info[$latestVersion]['type'] === 'metapackage' - ) { - continue; - } - $modules[$packageName] = $info[$latestVersion]; if (isset($info['dev-master']['extra']['swissup'])) { @@ -107,22 +101,6 @@ public function getComponentsInfo() } } - $modules['swissup/subscription'] = [ - 'name' => 'swissup/subscription', - 'type' => 'subscription-plan', - 'description' => 'SwissUpLabs Modules Subscription', - 'version' => '', - 'extra' => [ - 'swissup' => [ - 'links' => [ - 'store' => 'https://swissuplabs.com', - 'download' => 'https://swissuplabs.com/subscription/customer/products/', - 'identity_key' => 'https://swissuplabs.com/license/customer/identity/' - ] - ] - ] - ]; - return $modules; } @@ -323,8 +301,8 @@ protected function fetch($url) /** * Get packages url from satis repository. * - * To do that we send a request to http://docs.swissuplabs.com/packages/packages.json, - * which returns actual packages list url: http://docs.swissuplabs.com/packages/include/all${sha1}.json + * To do that we send a request to https://swissup.github.io/packages-latest/packages.json, + * which returns actual packages list url: https://swissup.github.io/packages-latest/include/all${sha1}.json * * @return mixed */ @@ -362,7 +340,7 @@ protected function getPackagesUrlPrefix() \Magento\Store\Model\ScopeInterface::SCOPE_STORE ); - // docs.swissuplabs.com/packages + // swissup.github.io/packages-latest return ($useHttps ? 'https://' : 'http://') . $url; } } diff --git a/Model/Installer/Composer.php b/Model/Installer/Composer.php new file mode 100644 index 0000000..dc983aa --- /dev/null +++ b/Model/Installer/Composer.php @@ -0,0 +1,162 @@ +getRootDir() . '/vendor/composer/installed.json'); + + if (!$file->exists()) { + return true; + } + + return $file->read()['dev'] ?? true; + } + + /** + * Root requirements from composer.json + * + * @return array Package name => version constraint + */ + public function getRequirements() + { + $file = new JsonFile($this->getRootDir() . '/composer.json'); + $json = $file->exists() ? $file->read() : []; + + return array_merge($json['require'] ?? [], $json['require-dev'] ?? []); + } + + /** + * @return array Path => content (null if file does not exist) + */ + public function backupFiles() + { + $backup = []; + + foreach (['composer.json', 'composer.lock'] as $filename) { + $path = $this->getRootDir() . '/' . $filename; + $backup[$path] = is_file($path) ? file_get_contents($path) : null; + } + + return $backup; + } + + /** + * @param array $backup Result of backupFiles() + * @return void + * @throws \RuntimeException + */ + public function restoreFiles(array $backup) + { + foreach ($backup as $path => $content) { + if ($content === null) { + if (is_file($path) && !unlink($path)) { + throw new \RuntimeException(sprintf('Unable to delete %s file', $path)); + } + } elseif (file_put_contents($path, $content) === false) { + throw new \RuntimeException(sprintf('Unable to restore %s file', $path)); + } + } + } + + /** + * @param array $args + * @param OutputInterface $output + * @param boolean $interactive + * @return int Exit code + * @throws \RuntimeException + */ + public function run(array $args, OutputInterface $output, $interactive = true) + { + if (!$this->process->canRun()) { + throw new \RuntimeException(sprintf( + 'proc_open is disabled. Run the command manually: composer %s', + implode(' ', $args) + )); + } + + return $this->process->run($this->getCommand($args), $output, $interactive, $this->getEnv()); + } + + /** + * Run non-interactive composer command and return its stdout + * + * @param array $args + * @return string + * @throws \RuntimeException + */ + public function capture(array $args) + { + return $this->process->capture($this->getCommand($args), $this->getEnv()); + } + + /** + * @return boolean + */ + public function hasAuthJson() + { + return is_file($this->getRootDir() . '/auth.json'); + } + + /** + * @param array $args + * @return array + */ + private function getCommand(array $args) + { + $args[] = '--working-dir=' . $this->getRootDir(); + + return array_merge([$this->getComposerBin()], $args); + } + + /** + * @return array + */ + private function getEnv() + { + return ['COMPOSER_HOME' => false]; // unset Magento's var/composer_home + } + + /** + * @return string + */ + private function getComposerBin() + { + // vendor/composer/composer/src/Composer/Composer.php + $file = (new \ReflectionClass(\Composer\Composer::class))->getFileName(); + + return dirname($file, 3) . '/bin/composer'; + } + + /** + * @return string + */ + private function getRootDir() + { + return dirname($this->composerJsonFinder->findComposerJson()); + } +} diff --git a/Model/Installer/ComposerRepository.php b/Model/Installer/ComposerRepository.php new file mode 100644 index 0000000..3d2f33a --- /dev/null +++ b/Model/Installer/ComposerRepository.php @@ -0,0 +1,418 @@ +getKey() !== null; + } + + /** + * Add repository on top of the others (composer uses the first match) + * keeping composer.json formatting. + * Composer's JsonConfigSource is not used because since 2.9 it + * converts "repositories" object into the list. + * + * @return void + * @throws \RuntimeException + */ + public function enable() + { + if ($this->isEnabled()) { + return; + } + + $path = $this->getComposerJsonPath(); + $json = (new JsonFile($path))->read(); + $manipulator = new JsonManipulator(file_get_contents($path)); + $config = [ + 'type' => self::TYPE, + 'url' => self::URL, + ]; + + $repositories = $json['repositories'] ?? null; + if ($repositories && array_keys($repositories) === range(0, count($repositories) - 1)) { + // addListItem is not available in older composer versions + $result = method_exists($manipulator, 'addListItem') + && $manipulator->addListItem('repositories', $config, false); + } else { + $result = $manipulator->addSubNode('repositories', self::ID, $config, false); + } + + if (!$result) { + throw new \RuntimeException(sprintf('Unable to update %s file', $path)); + } + + file_put_contents($path, $manipulator->getContents()); + } + + /** + * Remove repository keeping composer.json formatting + * + * @return void + * @throws \RuntimeException + */ + public function disable() + { + $key = $this->getKey(); + if ($key === null) { + return; + } + + $path = $this->getComposerJsonPath(); + $manipulator = new JsonManipulator(file_get_contents($path)); + + if (is_int($key)) { + // removeListItem is not available in older composer versions + $result = method_exists($manipulator, 'removeListItem') + && $manipulator->removeListItem('repositories', $key); + } else { + $result = $manipulator->removeSubNode('repositories', $key); + } + + if (!$result) { + throw new \RuntimeException(sprintf('Unable to update %s file', $path)); + } + + file_put_contents($path, $manipulator->getContents()); + } + + /** + * Store domain is used as username for swissuplabs repository + * + * @return string + */ + public function getDomain() + { + return (string) parse_url( + (string) $this->scopeConfig->getValue('web/unsecure/base_url'), + PHP_URL_HOST + ); + } + + /** + * Get credentials that composer uses: global auth.json, + * project's auth.json and COMPOSER_AUTH merged together. + * + * "http-basic" is read as a whole because older composer versions + * fail to parse "http-basic.ci.swissuplabs.com" key. + * + * @return array ['username' => string, 'password' => string] + * @throws \RuntimeException + */ + public function getCredentials() + { + if ($this->credentials === null) { + $data = json_decode($this->composer->capture(['config', 'http-basic']), true); + if (!is_array($data)) { + throw new \RuntimeException('Unable to read http-basic credentials from composer config'); + } + + $this->credentials = [ + 'username' => $data[self::HOSTNAME]['username'] ?? '', + 'password' => $data[self::HOSTNAME]['password'] ?? '', + ]; + } + + return $this->credentials; + } + + /** + * Saved username or store domain for the new credentials + * + * @return string + */ + public function getUsername() + { + return $this->getCredentials()['username'] ?: $this->getDomain(); + } + + /** + * Access keys are stored as space separated password + * + * @return string[] + */ + public function getKeys() + { + $password = trim($this->getCredentials()['password']); + + return $password === '' ? [] : preg_split('/\s+/', $password); + } + + /** + * Get the site where the key was issued. + * Key format: base64(domain):secret: + * + * @param string $key + * @return string|null + */ + public function getKeyDomain($key) + { + if (strpos($key, ':') === false) { + return null; + } + + $domain = base64_decode(strtok($key, ':'), true); + if ($domain === false || !preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/i', $domain)) { + return null; + } + + return $domain; + } + + /** + * Check the key alone (to not save invalid key along with valid ones) + * and save it with all currently used keys. + * + * @param string $key + * @return array Packages available with this key + * @throws AuthenticationException + * @throws \RuntimeException + */ + public function addKey($key) + { + $key = trim($key); + if ($key === '' || preg_match('/\s/', $key)) { + throw new \RuntimeException('Access key cannot be empty or contain spaces'); + } + + $username = $this->getUsername(); + $packages = $this->getPackages($username, $key); + + $keys = $this->getKeys(); + if (!in_array($key, $keys, true)) { + $keys[] = $key; + $this->saveCredentials($username, implode(' ', $keys)); + } + + return $packages; + } + + /** + * Remove the key from the project's auth.json file + * + * @param string $key + * @return boolean False if the key is still used from global auth.json + * @throws \RuntimeException + */ + public function removeKey($key) + { + $keys = $this->getKeys(); + if (!in_array($key, $keys, true)) { + throw new \RuntimeException('Access key is not found'); + } + + $keys = array_values(array_diff($keys, [$key])); + if ($keys) { + // project's entry overrides global one, so the key is not used anymore + $this->saveCredentials($this->getUsername(), implode(' ', $keys)); + return true; + } + + if ($this->composer->hasAuthJson()) { + $this->composer->capture(['config', '--unset', 'http-basic.' . self::HOSTNAME]); + $this->credentials = null; + } + + return !in_array($key, $this->getKeys(), true); + } + + /** + * Save credentials to the project's auth.json file. + * Password must contain all keys, including the ones from global auth.json, + * because project's entry overrides the global one. + * + * @param string $username + * @param string $password + * @return void + * @throws \RuntimeException + */ + public function saveCredentials($username, $password) + { + $this->composer->capture(['config', 'http-basic.' . self::HOSTNAME, $username, $password]); + $this->credentials = null; + } + + /** + * Fetch packages available for the given credentials + * + * @param string $username + * @param string $password + * @return array + * @throws AuthenticationException + * @throws \RuntimeException + */ + public function getPackages($username, $password) + { + $packages = $this->getPackagesBatch($username, [$password])[$password]; + + if ($packages instanceof \Exception) { + throw $packages; + } + + return $packages; + } + + /** + * @param string $username + * @param string[] $passwords + * @return array [password => array|\Exception] + */ + public function getPackagesBatch($username, array $passwords) + { + $requests = []; + foreach ($passwords as $password) { + $requests[$password] = ['url' => self::URL, 'password' => $password]; + } + + $responses = $this->fetchAll($username, $requests); + $packages = []; + $includeRequests = []; + + foreach ($responses as $password => $response) { + if ($response instanceof \Exception) { + $packages[$password] = $response; + continue; + } + + $packages[$password] = $response['packages'] ?? []; + foreach (array_keys($response['includes'] ?? []) as $include) { + $includeRequests[] = [ + 'url' => dirname(self::URL) . '/' . ltrim($include, '/'), + 'password' => $password, + ]; + } + } + + foreach ($this->fetchAll($username, $includeRequests) as $i => $response) { + $password = $includeRequests[$i]['password']; + $packages[$password] = $response instanceof \Exception + ? $response + : array_merge($packages[$password], $response['packages'] ?? []); + } + + return $packages; + } + + /** + * Send all the requests before reading any response, so that they are + * performed in parallel. + * + * @param string $username + * @param array $requests [id => ['url' => string, 'password' => string]] + * @return array [id => array|\Exception] + */ + private function fetchAll($username, array $requests) + { + $deferred = []; + foreach ($requests as $id => $request) { + $deferred[$id] = $this->asyncClient->request(new Request( + $request['url'], + Request::METHOD_GET, + ['Authorization' => 'Basic ' . base64_encode($username . ':' . $request['password'])], + null + )); + } + + $responses = []; + foreach ($deferred as $id => $response) { + try { + $responses[$id] = $this->parse($username, $requests[$id]['url'], $response->get()); + } catch (\Exception $e) { + $responses[$id] = $e; + } + } + + return $responses; + } + + /** + * @param string $username + * @param string $url + * @param Response $response + * @return array + * @throws AuthenticationException + * @throws \RuntimeException + */ + private function parse($username, $url, Response $response) + { + $status = $response->getStatusCode(); + if ($status === 401 || $status === 403) { + throw new AuthenticationException(__( + 'Access denied for "%1". Make sure the domain is activated and the key is correct.', + $username + )); + } + + if ($status !== 200) { + throw new \RuntimeException(sprintf('%s returned %s response code', $url, $status)); + } + + $data = json_decode($response->getBody(), true); + if (!is_array($data)) { + throw new \RuntimeException(sprintf('%s returned malformed response', $url)); + } + + return $data; + } + + /** + * Find repository in composer.json by its hostname. + * The name may differ from self::ID if repo was added manually. + * + * @return string|int|null Object key or list index + */ + private function getKey() + { + $json = (new JsonFile($this->getComposerJsonPath()))->read(); + + foreach ($json['repositories'] ?? [] as $key => $repo) { + if (!is_array($repo) || empty($repo['url'])) { + continue; + } + + if (parse_url($repo['url'], PHP_URL_HOST) === self::HOSTNAME) { + return $key; + } + } + + return null; + } + + /** + * @return string + */ + private function getComposerJsonPath() + { + return $this->composerJsonFinder->findComposerJson(); + } +} diff --git a/Model/Installer/Process.php b/Model/Installer/Process.php new file mode 100644 index 0000000..7f5d6bd --- /dev/null +++ b/Model/Installer/Process.php @@ -0,0 +1,99 @@ +canRun()) { + throw new \RuntimeException('proc_open is disabled'); + } + + $command[] = $output->isDecorated() ? '--ansi' : '--no-ansi'; + + if (!$interactive) { + $command[] = '--no-interaction'; + } + + if ($output->isQuiet()) { + $command[] = '--quiet'; + } elseif ($output->isDebug()) { + $command[] = '-vvv'; + } elseif ($output->isVeryVerbose()) { + $command[] = '-vv'; + } elseif ($output->isVerbose()) { + $command[] = '-v'; + } + + array_unshift($command, PHP_BINARY); + + $process = new SymfonyProcess($command, BP, $env); + $process->setTimeout(null); + + if ($interactive && SymfonyProcess::isTtySupported()) { + $process->setTty(true); + return $process->run(); + } + + $errorOutput = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + + return $process->run(function ($type, $buffer) use ($output, $errorOutput) { + $stream = $type === SymfonyProcess::ERR ? $errorOutput : $output; + $stream->write($buffer, false, OutputInterface::OUTPUT_RAW); + }); + } + + /** + * Run non-interactive command and return its stdout + * + * @param array $command Script path and its arguments + * @param array $env + * @return string + * @throws \RuntimeException + */ + public function capture(array $command, array $env = []) + { + if (!$this->canRun()) { + throw new \RuntimeException('proc_open is disabled'); + } + + array_unshift($command, PHP_BINARY); + $command[] = '--no-ansi'; + $command[] = '--no-interaction'; + + $process = new SymfonyProcess($command, BP, $env); + $process->setTimeout(null); + $process->run(); + + if (!$process->isSuccessful()) { + $error = preg_replace('/\s+/', ' ', trim($process->getErrorOutput())); + throw new \RuntimeException($error ?: sprintf('Command failed with exit code %s', $process->getExitCode())); + } + + return $process->getOutput(); + } +} diff --git a/Model/Module.php b/Model/Module.php deleted file mode 100644 index 3f49e50..0000000 --- a/Model/Module.php +++ /dev/null @@ -1,307 +0,0 @@ -licenseValidatorFactory = $licenseValidatorFactory; - $this->installerFactory = $installerFactory; - $this->packageInfo = $packageInfo; - $this->remoteComponents = $remoteComponents; - $this->localComponents = $localComponents; - parent::__construct($context, $registry, $resource, $resourceCollection, $data); - } - - /** - * @return void - */ - protected function _construct() - { - $this->_init('Swissup\Core\Model\ResourceModel\Module'); - } - - public function load($modelId, $field = null) - { - parent::load($modelId, $field); - - $this->setId($modelId); - - try { - // array_filter to remove empty items caused by non-magento modules requirements - $depends = array_filter($this->packageInfo->getRequire($this->getCode())); - $version = $this->packageInfo->getVersion($this->getCode()); - $packageName = $this->packageInfo->getPackageName($this->getCode()); - } catch (\Exception $e) { - $depends = []; - $version = $e->getMessage() . ' (Third-party composer.json)'; - $packageName = $this->getCode(); - } - $this->setDepends($depends); - $this->setVersion($version); - $this->setPackageName($packageName); - - return $this; - } - - public function up() - { - $this->getInstaller()->up(); - } - - public function getInstaller() - { - if (null === $this->installer) { - $this->installer = $this->installerFactory->create(['module' => $this]); - } - return $this->installer; - } - - /** - * Check is module already installed at any store - * - * @return boolean - */ - public function isInstalled() - { - return $this->getDataVersion() && $this->getOldStores(); - } - - public function validateLicense() - { - return $this->licenseValidatorFactory->create(['module' => $this])->validate(); - } - - public function getRemote() - { - $remoteData = $this->remoteComponents->getItemById($this->getId()); - if (!$remoteData) { - return false; - } - return new \Magento\Framework\DataObject($remoteData); - } - - public function getLocal() - { - $localData = $this->localComponents->getItemById($this->getId()); - if (!$localData) { - return false; - } - return new \Magento\Framework\DataObject($localData); - } - - /** - * Prepare store ids - * - * @return \Magento\Framework\Model\AbstractModel - */ - public function beforeSave() - { - $oldStores = $this->getOldStores(); - $newStores = $this->getNewStoreIds(); - if (is_array($newStores)) { - $stores = array_merge($oldStores, $newStores); - $this->setStoreIds(implode(',', array_unique($stores))); - } - return parent::beforeSave(); - } - - /** - * Retieve store ids, where the module is already installed - * - * @return array - */ - public function getOldStores() - { - $ids = $this->getStoreIds(); - if (null === $ids || '' === $ids) { - return array(); - } - if (!is_array($ids)) { - $ids = explode(',', $ids); - } - return $ids; - } - - /** - * Get the stores, where the module should be installed or reinstalled - * - * @return array - */ - public function getNewStores() - { - $storeIds = $this->getNewStoreIds(); - if (!$storeIds) { - return []; - } - return $storeIds; - } - - /** - * Set the stores, where the module should be installed or reinstalled - * - * @param array $ids - * @return ModuleInterface - */ - public function setNewStores(array $ids) - { - $this->setData('new_store_ids', array_unique($ids)); - return $this; - } - - /** - * Retrieve module code - * - * @return int - */ - public function getId() - { - return $this->getData(self::CODE); - } - - /** - * Retrieve module code - * - * @return string - */ - public function getCode() - { - return $this->getData(self::CODE); - } - - /** - * Retrieve module data version - * - * @return string - */ - public function getDataVersion() - { - return $this->getData(self::DATA_VERSION); - } - - /** - * Retrieve module identity key - * - * @return string - */ - public function getIdentityKey() - { - return $this->getData(self::IDENTITY_KEY); - } - - /** - * Retrieve module store ids - * - * @return string - */ - public function getStoreIds() - { - return $this->getData(self::STORE_IDS); - } - - /** - * Set code - * - * @param string $id - * @return ModuleInterface - */ - public function setId($id) - { - return $this->setData(self::CODE, $id); - } - - /** - * Set code - * - * @param string $code - * @return ModuleInterface - */ - public function setCode($code) - { - return $this->setData(self::CODE, $code); - } - - /** - * Set data version - * - * @param string $dataVersion - * @return ModuleInterface - */ - public function setDataVersion($dataVersion) - { - return $this->setData(self::DATA_VERSION, $dataVersion); - } - - /** - * Set identity key - * - * @param string $identityKey - * @return ModuleInterface - */ - public function setIdentityKey($identityKey) - { - return $this->setData(self::IDENTITY_KEY, $identityKey); - } - - /** - * Set store ids - * - * @param string $storeIds Comma separated store ids - * @return ModuleInterface - */ - public function setStoreIds($storeIds) - { - return $this->setData(self::STORE_IDS, $storeIds); - } -} diff --git a/Model/Module/Installer.php b/Model/Module/Installer.php deleted file mode 100644 index 2ebed93..0000000 --- a/Model/Module/Installer.php +++ /dev/null @@ -1,93 +0,0 @@ -module = $module; - $this->moduleFactory = $moduleFactory; - $this->messageLogger = $messageLogger; - } - - /** - * 1. Run dependent modules upgrades - * 2. Run module upgrades on installed stores - * 3. Run module upgrades on new stores - * - * @return void - */ - public function up() - { - $oldStores = $this->module->getOldStores(); - $newStores = $this->module->getNewStores(); - if (!count($oldStores) && !count($newStores)) { - return; - } - - foreach ($this->module->getDepends() as $moduleCode) { - if (0 !== strpos($moduleCode, 'Swissup')) { - continue; - } - $this->getModuleObject($moduleCode)->up(); - } - - $this->module->save(); - } - - /** - * Retrieve singleton instance of error logger, used in upgrade file - * to write errors and module controller to read them. - * - * @return \Swissup\Core\Model\Module\MessageLogger - */ - public function getMessageLogger() - { - return $this->messageLogger; - } - - /** - * Returns loded module object with copied new_store_ids and skip_upgrade - * instructions into it - * - * @return Swissup\Core\Model\Module - */ - protected function getModuleObject($code) - { - $module = $this->moduleFactory->create() - ->load($code) - ->setNewStores($this->module->getNewStores()); - - if (!$module->getIdentityKey()) { - $module->setIdentityKey($this->module->getIdentityKey()); - } - - return $module; - } -} diff --git a/Model/Module/LicenseValidator.php b/Model/Module/LicenseValidator.php deleted file mode 100644 index 73c0ce7..0000000 --- a/Model/Module/LicenseValidator.php +++ /dev/null @@ -1,221 +0,0 @@ -request = $request; - $this->module = $module; - $this->scopeConfig = $scopeConfig; - $this->jsonHelper = $jsonHelper; - $this->curlFactory = $curlFactory; - } - - /** - * Checks if module should be validated - * - * @return boolean - */ - protected function canValidate() - { - return $this->module->getRemote() && - $this->module->getRemote()->getIdentityKeyLink(); - } - - /** - * Validate module using curl request - * - * @return boolean|array - */ - public function validate() - { - if (!$this->canValidate()) { - return true; - } - - $key = trim($this->module->getIdentityKey()); - if (empty($key)) { - return ['error' => ['Identity key is required']]; - } - - // key format is: encoded_site:secret_key:optional_suffix - $parts = explode(':', $key); - if (count($parts) < 3) { - return ['error' => ['Identity key is not valid']]; - } - list($site, $secret, $suffix) = explode(':', $key); - - try { - $client = $this->curlFactory->create(); - $client->setConfig(['maxredirects' => 5, 'timeout' =>30]); - $client->write( - \Laminas\Http\Request::METHOD_GET, - $this->getUrl($site, [ - 'key' => $secret, - 'suffix' => $suffix, - ]) - ); - $responseString = $client->read(); - $responseParts = preg_split('|(?:\r\n){2}|m', $responseString, 2); - $responseBody = trim($responseParts[1] ?? ''); - - $client->close(); - } catch (\Exception $e) { - return [ - 'error' => [ - 'Response error: %1', $e->getMessage() - ], - 'response' => $e->getTraceAsString() - ]; - } - - return $this->parseResponse($responseBody); - } - - /** - * Parse server response - * - * @param string $response - *
-     * "{success: true}" or "{error: error_message}"
-     * 
- */ - protected function parseResponse($response) - { - try { - $result = $this->jsonHelper->jsonDecode($response); - if (!is_array($result)) { - throw new \Exception('Decoding failed'); - } - if (is_array($result) && isset($result['error'])) { - $result['error'][0] = $this->convertMagento1xTranslation($result['error'][0]); - } - } catch (\Exception $e) { - $result = [ - 'error' => [ - 'Sorry, try again in five minutes. Validation response parsing error: %1', - $e->getMessage() - ], - 'response' => $response - ]; - } - return $result; - } - - /** - * Convert Magento 1.x translation phrase into 2.x standard: - * - * %s replaced with %1...%n - * - * @param string $text - * @return string - */ - protected function convertMagento1xTranslation($text) - { - $parts = explode('%s', $text); - $result = $parts[0]; - unset($parts[0]); - foreach ($parts as $i => $part) { - $result .= '%' . $i . $part; - } - return $result; - } - - /** - * Retrieve validation url according to the encoded $site - * - * @param string $site Base64 encoded site url - * @param array $queryParams - */ - protected function getUrl($site, array $queryParams = []) - { - $useHttps = $this->scopeConfig->getValue( - self::XML_USE_HTTPS_PATH, - \Magento\Store\Model\ScopeInterface::SCOPE_STORE - ); - $url = $this->scopeConfig->getValue( - self::XML_VALIDATE_URL_PATH, - \Magento\Store\Model\ScopeInterface::SCOPE_STORE - ); - - $site = base64_decode($site); - $url = ($useHttps ? 'https://' : 'http://') . rtrim($site, '/ ') . $url; - $url .= '?' . http_build_query(array_merge($this->getQueryParams(), $queryParams)); - - return $url; - } - - /** - * Prepare query parameters for the request - * - * @return array - */ - protected function getQueryParams() - { - $purchaseCode = $this->module->getRemote()->getPurchaseCode(); - if (!$purchaseCode) { - $purchaseCode = $this->module->getCode(); - } - - $domain = $this->module->getDomain(); - if (empty($domain)) { - $domain = $this->request->getHttpHost(); - } - - $params = [ - 'module' => $purchaseCode, - 'module_code' => $this->module->getCode(), - 'domain' => $domain, - ]; - - if ($this->module->getConfigSection()) { - $params['config_section'] = $this->module->getConfigSection(); - } - - return $params; - } -} diff --git a/Model/Module/MessageLogger.php b/Model/Module/MessageLogger.php deleted file mode 100644 index 379a355..0000000 --- a/Model/Module/MessageLogger.php +++ /dev/null @@ -1,58 +0,0 @@ - array(), - 'notices' => array(), - 'success' => array() - ); - - /** - * @param string $group - * @param mixed $error array or string with error message - *
-     *  message required
-     *  trace   optional
-     * 
- */ - public function addError($group, $error) - { - $this->messages['errors'][$group][] = $error; - } - - public function getErrors() - { - return $this->messages['errors']; - } - - /** - * @param string $group - * @param string $notice - */ - public function addNotice($group, $notice) - { - $this->messages['notices'][$group][] = $notice; - } - - public function getNotices() - { - return $this->messages['notices']; - } - - /** - * @param string $group - * @param string $success - */ - public function addSuccess($group, $success) - { - $this->messages['success'][$group][] = $success; - } - - public function getSuccess() - { - return $this->messages['success']; - } -} diff --git a/Model/ResourceModel/Module.php b/Model/ResourceModel/Module.php deleted file mode 100644 index 0e490c3..0000000 --- a/Model/ResourceModel/Module.php +++ /dev/null @@ -1,23 +0,0 @@ -_init('swissup_core_module', 'code'); - } -} diff --git a/Model/ResourceModel/Module/Collection.php b/Model/ResourceModel/Module/Collection.php deleted file mode 100644 index ffffaa1..0000000 --- a/Model/ResourceModel/Module/Collection.php +++ /dev/null @@ -1,25 +0,0 @@ -_init('Swissup\Core\Model\Module', 'Swissup\Core\Model\ResourceModel\Module'); - } -} diff --git a/README.md b/README.md index 6c885b0..64d9741 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -# Core +# Swissup Core -Swissup_Core module adds menu and config entries to Magento backend. It also -utilize some common tasks used by other modules. +This module ships Swissup Installer and adds Swissup menu and config entries +to Magento backend. It also provides a set of common tasks used by other modules. ## Installation @@ -10,6 +10,25 @@ composer require swissup/module-core bin/magento setup:upgrade ``` +## Swissup Installer + +Available commands + +Command | Description +------------------------------------------------|--------------------------------------- +`bin/magento swissup:channel:enable` | Add SwissupLabs repository to composer.json file +`bin/magento swissup:channel:disable` | Remove SwissupLabs repository from composer.json file +**Authorization** | +`bin/magento swissup:auth:add {key}` | Add SwissupLabs access key +`bin/magento swissup:auth:check` | Display SwissupLabs access keys currently in use with count of available packages per key +`bin/magento swissup:auth:remove {key}` | Remove SwissupLabs access key +`bin/magento swissup:auth:show` | Display SwissupLabs access username and password currently in use +**Packages** | +`bin/magento swissup:package:require {package}` | Download SwissupLabs package(s) using composer and run setup:upgrade +`bin/magento swissup:package:install {package}` | Run installer for downloaded package +`bin/magento swissup:package:update` | Update SwissupLabs package(s) using composer and run setup:upgrade +`bin/magento swissup:package:remove {package}` | Remove SwissupLabs package(s) using composer and run setup:upgrade + ## Popup Message Manager Popup message manager allows to show regular Magento messages with additional diff --git a/Ui/DataProvider/ModuleListingDataProvider.php b/Ui/DataProvider/ModuleListingDataProvider.php index 6928126..2b1dcd0 100644 --- a/Ui/DataProvider/ModuleListingDataProvider.php +++ b/Ui/DataProvider/ModuleListingDataProvider.php @@ -10,9 +10,6 @@ use Magento\Framework\View\Element\UiComponent\DataProvider\DataProvider; use Swissup\Core\Model\ComponentList\Loader; -/** - * Swissup modules grid data, taken from the component list instead of the database - */ class ModuleListingDataProvider extends DataProvider { const SEARCH_FIELDS = ['code', 'name']; @@ -62,7 +59,7 @@ public function __construct( public function getData() { $criteria = $this->getSearchCriteria(); - $items = $this->loader->getItems(); + $items = $this->loader->getModuleItems(); foreach ($criteria->getFilterGroups() as $group) { foreach ($group->getFilters() as $filter) { @@ -86,7 +83,7 @@ public function getData() break; } - $field = $sortOrder->getField(); + $field = (string) $sortOrder->getField(); $result = $this->normalize($field, $a[$field] ?? null) <=> $this->normalize($field, $b[$field] ?? null); diff --git a/composer.json b/composer.json index f6a117c..b4b1681 100644 --- a/composer.json +++ b/composer.json @@ -1,9 +1,16 @@ { "name": "swissup/module-core", - "description": "Swissup core module. It's purpose is to add Swissup menu and config entries", + "description": "Swissup core module. Your starting point to use SwissupLabs packages.", "type": "magento2-module", "version": "1.13.1", "license": "OSL-3.0", + "require": { + "php": "^8.0", + "composer/composer": "^2.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/console": "^4.4 || ^5.0 || ^6.0 || ^7.0", + "symfony/process": "^4.4 || ^5.0 || ^6.0 || ^7.0" + }, "autoload": { "files": [ "registration.php" ], "psr-4": { diff --git a/etc/db_schema.xml b/etc/db_schema.xml deleted file mode 100644 index 5884aa2..0000000 --- a/etc/db_schema.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
diff --git a/etc/db_schema_whitelist.json b/etc/db_schema_whitelist.json deleted file mode 100644 index fa3f925..0000000 --- a/etc/db_schema_whitelist.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "swissup_core_module": { - "column": { - "code": true, - "data_version": true, - "identity_key": true, - "store_ids": true, - "name": true, - "description": true, - "keywords": true, - "type": true, - "version": true, - "release_date": true, - "link": true, - "download_link": true, - "identity_key_link": true, - "latest_version": true - }, - "index": { - "SWISSUP_CORE_MODULE_CODE_NAME_DESCRIPTION_KEYWORDS": true - }, - "constraint": { - "PRIMARY": true - } - } -} \ No newline at end of file diff --git a/etc/di.xml b/etc/di.xml index 97297fd..bc1ab1d 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -7,6 +7,16 @@ Swissup\Core\Console\Command\ModuleListCommand Swissup\Core\Console\Command\ThemeCreateCommand Swissup\Core\Console\Command\StoreCreateCommand + Swissup\Core\Console\Command\Installer\ChannelEnableCommand + Swissup\Core\Console\Command\Installer\ChannelDisableCommand + Swissup\Core\Console\Command\Installer\PackageRequireCommand + Swissup\Core\Console\Command\Installer\PackageUpdateCommand + Swissup\Core\Console\Command\Installer\PackageInstallCommand + Swissup\Core\Console\Command\Installer\PackageRemoveCommand + Swissup\Core\Console\Command\Installer\AuthShowCommand + Swissup\Core\Console\Command\Installer\AuthCheckCommand + Swissup\Core\Console\Command\Installer\AuthAddCommand + Swissup\Core\Console\Command\Installer\AuthRemoveCommand diff --git a/etc/module.xml b/etc/module.xml index 1d8b430..7209af9 100644 --- a/etc/module.xml +++ b/etc/module.xml @@ -1,5 +1,5 @@ - + diff --git a/view/adminhtml/ui_component/swissup_module_manager.xml b/view/adminhtml/ui_component/swissup_module_manager.xml index fdeb965..3f23e31 100644 --- a/view/adminhtml/ui_component/swissup_module_manager.xml +++ b/view/adminhtml/ui_component/swissup_module_manager.xml @@ -103,11 +103,11 @@ - Read Documentation + Documentation docs_link - Read Changelog + Changelog changelog_link