From a8a71188bdf69c6b644abf4726287b8aeb4d856f Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Wed, 16 Sep 2026 11:52:22 +0300 Subject: [PATCH 01/34] WIP --- Console/Command/Installer/AuthAddCommand.php | 59 +++ .../Command/Installer/AuthRemoveCommand.php | 52 +++ Console/Command/Installer/AuthShowCommand.php | 53 +++ .../Installer/PackageAbstractCommand.php | 263 +++++++++++++ .../Installer/PackageRemoveCommand.php | 122 ++++++ .../Installer/PackageRequireCommand.php | 154 ++++++++ .../Command/Installer/RepoDisableCommand.php | 51 +++ .../Command/Installer/RepoEnableCommand.php | 116 ++++++ Model/Composer.php | 167 ++++++++ Model/ComposerRepository.php | 366 ++++++++++++++++++ Model/Process.php | 99 +++++ README.md | 18 + etc/di.xml | 7 + 13 files changed, 1527 insertions(+) create mode 100644 Console/Command/Installer/AuthAddCommand.php create mode 100644 Console/Command/Installer/AuthRemoveCommand.php create mode 100644 Console/Command/Installer/AuthShowCommand.php create mode 100644 Console/Command/Installer/PackageAbstractCommand.php create mode 100644 Console/Command/Installer/PackageRemoveCommand.php create mode 100644 Console/Command/Installer/PackageRequireCommand.php create mode 100644 Console/Command/Installer/RepoDisableCommand.php create mode 100644 Console/Command/Installer/RepoEnableCommand.php create mode 100644 Model/Composer.php create mode 100644 Model/ComposerRepository.php create mode 100644 Model/Process.php diff --git a/Console/Command/Installer/AuthAddCommand.php b/Console/Command/Installer/AuthAddCommand.php new file mode 100644 index 0000000..3798628 --- /dev/null +++ b/Console/Command/Installer/AuthAddCommand.php @@ -0,0 +1,59 @@ +repository = $repository; + parent::__construct(); + } + + protected function configure() + { + $this->setName('swissup:auth:add') + ->setDescription('Add 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:repo:enable' + ); + } + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + return Cli::RETURN_FAILURE; + } + + return Cli::RETURN_SUCCESS; + } +} diff --git a/Console/Command/Installer/AuthRemoveCommand.php b/Console/Command/Installer/AuthRemoveCommand.php new file mode 100644 index 0000000..3e1ef11 --- /dev/null +++ b/Console/Command/Installer/AuthRemoveCommand.php @@ -0,0 +1,52 @@ +repository = $repository; + parent::__construct(); + } + + protected function configure() + { + $this->setName('swissup:auth:remove') + ->setDescription('Remove 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..940023f --- /dev/null +++ b/Console/Command/Installer/AuthShowCommand.php @@ -0,0 +1,53 @@ +repository = $repository; + parent::__construct(); + } + + protected function configure() + { + $this->setName('swissup:auth:show') + ->setDescription('Display your access keys'); + 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; + } + + $table = new Table($output); + $table->setHeaders(['Provider', 'Key']); + + foreach ($keys as $key) { + $table->addRow([$this->repository->getKeyDomain($key) ?: '', $key]); + } + + $table->render(); + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + return Cli::RETURN_FAILURE; + } + + return Cli::RETURN_SUCCESS; + } +} diff --git a/Console/Command/Installer/PackageAbstractCommand.php b/Console/Command/Installer/PackageAbstractCommand.php new file mode 100644 index 0000000..f6804ea --- /dev/null +++ b/Console/Command/Installer/PackageAbstractCommand.php @@ -0,0 +1,263 @@ +directoryList = $directoryList; + $this->composer = $composer; + $this->repository = $repository; + $this->process = $process; + $this->appState = $appState; + $this->maintenanceMode = $maintenanceMode; + $this->cleanupFiles = $cleanupFiles; + $this->cacheManager = $cacheManager; + parent::__construct(); + } + + /** + * Package name without version constraint: a/b=2.0, a/b:^2.0 + * + * @param string $package + * @return string + */ + protected function getPackageName($package) + { + return strtolower(strtok($package, ':= ')); + } + + /** + * @return array + */ + protected function getInstallArgs() + { + $args = ['install', '--no-progress']; + + if (!$this->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) + )); + } + } + + /** + * 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/PackageRemoveCommand.php b/Console/Command/Installer/PackageRemoveCommand.php new file mode 100644 index 0000000..7da72c0 --- /dev/null +++ b/Console/Command/Installer/PackageRemoveCommand.php @@ -0,0 +1,122 @@ +setName('swissup:remove') + ->setDescription('Remove swissup 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 (strpos($name, 'swissup/') !== 0) { + 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..d2d695f --- /dev/null +++ b/Console/Command/Installer/PackageRequireCommand.php @@ -0,0 +1,154 @@ +setName('swissup:require') + ->setDescription('Download swissup 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; + } + + /** + * Offer to run swissup:repo:enable when repository or access key is missing + * + * @param InputInterface $input + * @param OutputInterface $output + * @return boolean + * @throws \RuntimeException + */ + private 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:repo:enable first.'); + } + + $question = new ConfirmationQuestion( + sprintf('%s Run swissup:repo:enable now? [Y/n] ', $message), + true + ); + if (!$this->getHelper('question')->ask($input, $output, $question)) { + return false; + } + + return $this->getApplication() + ->find('swissup:repo:enable') + ->run(new ArrayInput([]), $output) === Cli::RETURN_SUCCESS; + } + + /** + * 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:repo: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/RepoDisableCommand.php b/Console/Command/Installer/RepoDisableCommand.php new file mode 100644 index 0000000..6c42ea0 --- /dev/null +++ b/Console/Command/Installer/RepoDisableCommand.php @@ -0,0 +1,51 @@ +repository = $repository; + parent::__construct(); + } + + protected function configure() + { + $this->setName('swissup:repo:disable') + ->setAliases(['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/RepoEnableCommand.php b/Console/Command/Installer/RepoEnableCommand.php new file mode 100644 index 0000000..e008568 --- /dev/null +++ b/Console/Command/Installer/RepoEnableCommand.php @@ -0,0 +1,116 @@ +repository = $repository; + parent::__construct(); + } + + protected function configure() + { + $this->setName('swissup:repo:enable') + ->setAliases(['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/Model/Composer.php b/Model/Composer.php new file mode 100644 index 0000000..4941202 --- /dev/null +++ b/Model/Composer.php @@ -0,0 +1,167 @@ +composerJsonFinder = $composerJsonFinder; + $this->process = $process; + } + + /** + * Check if packages were installed without --no-dev flag + * + * @return boolean + */ + public function isDevInstalled() + { + $file = new JsonFile($this->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/ComposerRepository.php b/Model/ComposerRepository.php new file mode 100644 index 0000000..0679b39 --- /dev/null +++ b/Model/ComposerRepository.php @@ -0,0 +1,366 @@ +composerJsonFinder = $composerJsonFinder; + $this->scopeConfig = $scopeConfig; + $this->curlFactory = $curlFactory; + $this->composer = $composer; + } + + /** + * @return boolean + */ + public function isEnabled() + { + return $this->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) + { + $response = $this->fetch(self::URL, $username, $password); + + // includes are relative to the repository base url + foreach (array_keys($response['includes'] ?? []) as $include) { + $url = dirname(self::URL) . '/' . ltrim($include, '/'); + $response['packages'] = array_merge( + $response['packages'] ?? [], + $this->fetch($url, $username, $password)['packages'] ?? [] + ); + } + + return $response['packages'] ?? []; + } + + /** + * @param string $url + * @param string $username + * @param string $password + * @return array + * @throws AuthenticationException + * @throws \RuntimeException + */ + private function fetch($url, $username, $password) + { + (new \Monolog\Logger('custom')) + ->pushHandler((new \Monolog\Handler\StreamHandler(BP . '/var/log/custom.log'))->setFormatter(new \Monolog\Formatter\LineFormatter(null, null, true, true))) + ->debug(print_r(__METHOD__, true)); + $client = $this->curlFactory->create(); + $client->setOption(CURLOPT_FOLLOWLOCATION, true); + $client->setOption(CURLOPT_MAXREDIRS, 5); + $client->setTimeout(30); + $client->setCredentials($username, $password); + $client->get($url); + + $status = $client->getStatus(); + 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($client->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/Process.php b/Model/Process.php new file mode 100644 index 0000000..297c73a --- /dev/null +++ b/Model/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/README.md b/README.md index 6c885b0..72fa95c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,24 @@ composer require swissup/module-core bin/magento setup:upgrade ``` +## Swissup installer + +Aavailable commands + +Command | Description +----------------------------------------|--------------------------------------- +`bin/magento swissup:repo:enable` | Add repo to composer.json file +`bin/magento swissup:repo:disable` | Remove repo from composer.json file +**Authorization** | +`bin/magento swissup:auth:add {key}` | Add auth key +`bin/magento swissup:auth:remove {key}` | Remove auth key +`bin/magento swissup:auth:show` | Display auth keys info +**Packages** | +`bin/magento swissup:require {package}` | Download package +`bin/magento swissup:install {package}` | Run installer for downloaded package +`bin/magento swissup:remove {package}` | Remove package +`bin/magento swissup:update` | Update `swissup/*` packages + ## Popup Message Manager Popup message manager allows to show regular Magento messages with additional diff --git a/etc/di.xml b/etc/di.xml index 97297fd..395e83a 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -7,6 +7,13 @@ Swissup\Core\Console\Command\ModuleListCommand Swissup\Core\Console\Command\ThemeCreateCommand Swissup\Core\Console\Command\StoreCreateCommand + Swissup\Core\Console\Command\Installer\RepoEnableCommand + Swissup\Core\Console\Command\Installer\RepoDisableCommand + Swissup\Core\Console\Command\Installer\PackageRequireCommand + Swissup\Core\Console\Command\Installer\PackageRemoveCommand + Swissup\Core\Console\Command\Installer\AuthShowCommand + Swissup\Core\Console\Command\Installer\AuthAddCommand + Swissup\Core\Console\Command\Installer\AuthRemoveCommand From 434bf247e9f618de20babb1d88d2e91586629da1 Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Wed, 16 Sep 2026 12:03:03 +0300 Subject: [PATCH 02/34] WIP --- Console/Command/Installer/AuthAddCommand.php | 2 +- .../Command/Installer/AuthRemoveCommand.php | 2 +- Console/Command/Installer/AuthShowCommand.php | 2 +- .../Installer/PackageAbstractCommand.php | 57 ++++++++++++++++-- .../Command/Installer/RepoDisableCommand.php | 2 +- .../Command/Installer/RepoEnableCommand.php | 2 +- Model/{ => Installer}/Composer.php | 2 +- Model/{ => Installer}/ComposerRepository.php | 2 +- Model/Installer/DisabledModules.php | 60 +++++++++++++++++++ Model/{ => Installer}/Process.php | 2 +- 10 files changed, 121 insertions(+), 12 deletions(-) rename Model/{ => Installer}/Composer.php (99%) rename Model/{ => Installer}/ComposerRepository.php (99%) create mode 100644 Model/Installer/DisabledModules.php rename Model/{ => Installer}/Process.php (98%) diff --git a/Console/Command/Installer/AuthAddCommand.php b/Console/Command/Installer/AuthAddCommand.php index 3798628..d15ceab 100644 --- a/Console/Command/Installer/AuthAddCommand.php +++ b/Console/Command/Installer/AuthAddCommand.php @@ -2,7 +2,7 @@ namespace Swissup\Core\Console\Command\Installer; use Magento\Framework\Console\Cli; -use Swissup\Core\Model\ComposerRepository; +use Swissup\Core\Model\Installer\ComposerRepository; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; diff --git a/Console/Command/Installer/AuthRemoveCommand.php b/Console/Command/Installer/AuthRemoveCommand.php index 3e1ef11..c337bf1 100644 --- a/Console/Command/Installer/AuthRemoveCommand.php +++ b/Console/Command/Installer/AuthRemoveCommand.php @@ -2,7 +2,7 @@ namespace Swissup\Core\Console\Command\Installer; use Magento\Framework\Console\Cli; -use Swissup\Core\Model\ComposerRepository; +use Swissup\Core\Model\Installer\ComposerRepository; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; diff --git a/Console/Command/Installer/AuthShowCommand.php b/Console/Command/Installer/AuthShowCommand.php index 940023f..3e8c57a 100644 --- a/Console/Command/Installer/AuthShowCommand.php +++ b/Console/Command/Installer/AuthShowCommand.php @@ -2,7 +2,7 @@ namespace Swissup\Core\Console\Command\Installer; use Magento\Framework\Console\Cli; -use Swissup\Core\Model\ComposerRepository; +use Swissup\Core\Model\Installer\ComposerRepository; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputInterface; diff --git a/Console/Command/Installer/PackageAbstractCommand.php b/Console/Command/Installer/PackageAbstractCommand.php index f6804ea..f81c1af 100644 --- a/Console/Command/Installer/PackageAbstractCommand.php +++ b/Console/Command/Installer/PackageAbstractCommand.php @@ -8,12 +8,14 @@ use Magento\Framework\App\State\CleanupFiles; use Magento\Framework\Console\Cli; use Magento\Framework\Setup\Declaration\Schema\FileSystem\Csv; -use Swissup\Core\Model\Composer; -use Swissup\Core\Model\ComposerRepository; -use Swissup\Core\Model\Process; +use Swissup\Core\Model\Installer\Composer; +use Swissup\Core\Model\Installer\ComposerRepository; +use Swissup\Core\Model\Installer\DisabledModules; +use Swissup\Core\Model\Installer\Process; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; abstract class PackageAbstractCommand extends Command { @@ -28,6 +30,7 @@ abstract class PackageAbstractCommand extends Command protected CleanupFiles $cleanupFiles; protected CacheManager $cacheManager; protected DirectoryList $directoryList; + protected DisabledModules $disabledModules; /** * Dependencies are injected (not proxied) on purpose: @@ -41,9 +44,11 @@ public function __construct( MaintenanceMode $maintenanceMode, CleanupFiles $cleanupFiles, CacheManager $cacheManager, - DirectoryList $directoryList + DirectoryList $directoryList, + DisabledModules $disabledModules ) { $this->directoryList = $directoryList; + $this->disabledModules = $disabledModules; $this->composer = $composer; $this->repository = $repository; $this->process = $process; @@ -116,6 +121,10 @@ protected function validate(array $manualCommands, $isDryRun) */ protected function runAndUpgrade(array $args, InputInterface $input, OutputInterface $output) { + if (!$this->confirmDisabledModules($input, $output)) { + return Cli::RETURN_FAILURE; + } + $interactive = $input->isInteractive(); $installArgs = $this->getInstallArgs(); // must be read before vendor is changed $backup = $this->composer->backupFiles(); @@ -174,6 +183,46 @@ protected function runAndUpgrade(array $args, InputInterface $input, OutputInter } } + /** + * Ask before running setup:upgrade that will drop the tables + * of the modules disabled in app/etc/config.php + * + * @param InputInterface $input + * @param OutputInterface $output + * @return boolean + */ + private function confirmDisabledModules(InputInterface $input, OutputInterface $output) + { + $modules = $this->disabledModules->getNamesWithDbSchema(); + if (!$modules) { + return true; + } + + $output->writeln( + 'This command runs `setup:upgrade --safe-mode=1` ' . + 'that drops the database tables of the disabled modules:' + ); + + foreach ($modules as $module) { + $output->writeln(' - ' . $module); + } + + $output->writeln( + 'Enable the modules or remove their packages to keep the tables. ' . + 'Otherwise their data is dumped into ' . $this->getSchemaDumpsDir() . '' + ); + + if (!$input->isInteractive()) { + return true; + } + + return $this->getHelper('question')->ask( + $input, + $output, + new ConfirmationQuestion('Continue? [y/N] ', false) + ); + } + /** * Csv dumps created by setup:upgrade --safe-mode * diff --git a/Console/Command/Installer/RepoDisableCommand.php b/Console/Command/Installer/RepoDisableCommand.php index 6c42ea0..9c8e55a 100644 --- a/Console/Command/Installer/RepoDisableCommand.php +++ b/Console/Command/Installer/RepoDisableCommand.php @@ -2,7 +2,7 @@ namespace Swissup\Core\Console\Command\Installer; use Magento\Framework\Console\Cli; -use Swissup\Core\Model\ComposerRepository; +use Swissup\Core\Model\Installer\ComposerRepository; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; diff --git a/Console/Command/Installer/RepoEnableCommand.php b/Console/Command/Installer/RepoEnableCommand.php index e008568..0df5c6f 100644 --- a/Console/Command/Installer/RepoEnableCommand.php +++ b/Console/Command/Installer/RepoEnableCommand.php @@ -2,7 +2,7 @@ namespace Swissup\Core\Console\Command\Installer; use Magento\Framework\Console\Cli; -use Swissup\Core\Model\ComposerRepository; +use Swissup\Core\Model\Installer\ComposerRepository; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; diff --git a/Model/Composer.php b/Model/Installer/Composer.php similarity index 99% rename from Model/Composer.php rename to Model/Installer/Composer.php index 4941202..3cf2c12 100644 --- a/Model/Composer.php +++ b/Model/Installer/Composer.php @@ -1,6 +1,6 @@ fullModuleList = $fullModuleList; + $this->moduleList = $moduleList; + $this->componentRegistrar = $componentRegistrar; + } + + /** + * @return string[] + */ + public function getNames() + { + return array_values( + array_diff($this->fullModuleList->getNames(), $this->moduleList->getNames()) + ); + } + + /** + * Declarative schema drops the tables of such modules during setup:upgrade: + * db_schema.xml is read for the enabled modules only, while + * db_schema_whitelist.json - the file that allows the drop - is read for all of them. + * + * @return string[] + */ + public function getNamesWithDbSchema() + { + $result = []; + + foreach ($this->getNames() as $name) { + $path = $this->componentRegistrar->getPath(ComponentRegistrar::MODULE, $name); + + if ($path && is_file($path . '/etc/db_schema_whitelist.json')) { + $result[] = $name; + } + } + + return $result; + } +} diff --git a/Model/Process.php b/Model/Installer/Process.php similarity index 98% rename from Model/Process.php rename to Model/Installer/Process.php index 297c73a..7f5d6bd 100644 --- a/Model/Process.php +++ b/Model/Installer/Process.php @@ -1,6 +1,6 @@ Date: Wed, 16 Sep 2026 16:06:00 +0300 Subject: [PATCH 03/34] Add swissup:update command - Update swissup/* packages only; -w also updates their 3rd party dependencies - Move ensureRepositoryEnabled into PackageAbstractCommand to share with update - Drop disabled modules check: setup:upgrade runs with --safe-mode=1 anyway Co-Authored-By: Claude Opus 5 --- .../Installer/PackageAbstractCommand.php | 88 ++++++-------- .../Installer/PackageRequireCommand.php | 39 ------ .../Installer/PackageUpdateCommand.php | 111 ++++++++++++++++++ Model/Installer/DisabledModules.php | 60 ---------- README.md | 2 +- etc/di.xml | 1 + 6 files changed, 152 insertions(+), 149 deletions(-) create mode 100644 Console/Command/Installer/PackageUpdateCommand.php delete mode 100644 Model/Installer/DisabledModules.php diff --git a/Console/Command/Installer/PackageAbstractCommand.php b/Console/Command/Installer/PackageAbstractCommand.php index f81c1af..825a9a8 100644 --- a/Console/Command/Installer/PackageAbstractCommand.php +++ b/Console/Command/Installer/PackageAbstractCommand.php @@ -10,9 +10,9 @@ use Magento\Framework\Setup\Declaration\Schema\FileSystem\Csv; use Swissup\Core\Model\Installer\Composer; use Swissup\Core\Model\Installer\ComposerRepository; -use Swissup\Core\Model\Installer\DisabledModules; use Swissup\Core\Model\Installer\Process; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ConfirmationQuestion; @@ -30,7 +30,6 @@ abstract class PackageAbstractCommand extends Command protected CleanupFiles $cleanupFiles; protected CacheManager $cacheManager; protected DirectoryList $directoryList; - protected DisabledModules $disabledModules; /** * Dependencies are injected (not proxied) on purpose: @@ -44,11 +43,9 @@ public function __construct( MaintenanceMode $maintenanceMode, CleanupFiles $cleanupFiles, CacheManager $cacheManager, - DirectoryList $directoryList, - DisabledModules $disabledModules + DirectoryList $directoryList ) { $this->directoryList = $directoryList; - $this->disabledModules = $disabledModules; $this->composer = $composer; $this->repository = $repository; $this->process = $process; @@ -110,6 +107,43 @@ protected function validate(array $manualCommands, $isDryRun) } } + /** + * Offer to run swissup:repo: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:repo:enable first.'); + } + + $question = new ConfirmationQuestion( + sprintf('%s Run swissup:repo:enable now? [Y/n] ', $message), + true + ); + if (!$this->getHelper('question')->ask($input, $output, $question)) { + return false; + } + + return $this->getApplication() + ->find('swissup:repo: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) @@ -121,10 +155,6 @@ protected function validate(array $manualCommands, $isDryRun) */ protected function runAndUpgrade(array $args, InputInterface $input, OutputInterface $output) { - if (!$this->confirmDisabledModules($input, $output)) { - return Cli::RETURN_FAILURE; - } - $interactive = $input->isInteractive(); $installArgs = $this->getInstallArgs(); // must be read before vendor is changed $backup = $this->composer->backupFiles(); @@ -183,46 +213,6 @@ protected function runAndUpgrade(array $args, InputInterface $input, OutputInter } } - /** - * Ask before running setup:upgrade that will drop the tables - * of the modules disabled in app/etc/config.php - * - * @param InputInterface $input - * @param OutputInterface $output - * @return boolean - */ - private function confirmDisabledModules(InputInterface $input, OutputInterface $output) - { - $modules = $this->disabledModules->getNamesWithDbSchema(); - if (!$modules) { - return true; - } - - $output->writeln( - 'This command runs `setup:upgrade --safe-mode=1` ' . - 'that drops the database tables of the disabled modules:' - ); - - foreach ($modules as $module) { - $output->writeln(' - ' . $module); - } - - $output->writeln( - 'Enable the modules or remove their packages to keep the tables. ' . - 'Otherwise their data is dumped into ' . $this->getSchemaDumpsDir() . '' - ); - - if (!$input->isInteractive()) { - return true; - } - - return $this->getHelper('question')->ask( - $input, - $output, - new ConfirmationQuestion('Continue? [y/N] ', false) - ); - } - /** * Csv dumps created by setup:upgrade --safe-mode * diff --git a/Console/Command/Installer/PackageRequireCommand.php b/Console/Command/Installer/PackageRequireCommand.php index d2d695f..f06a02c 100644 --- a/Console/Command/Installer/PackageRequireCommand.php +++ b/Console/Command/Installer/PackageRequireCommand.php @@ -2,12 +2,10 @@ namespace Swissup\Core\Console\Command\Installer; use Magento\Framework\Console\Cli; -use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ConfirmationQuestion; class PackageRequireCommand extends PackageAbstractCommand { @@ -83,43 +81,6 @@ private function getRequireArgs(array $packages) return $args; } - /** - * Offer to run swissup:repo:enable when repository or access key is missing - * - * @param InputInterface $input - * @param OutputInterface $output - * @return boolean - * @throws \RuntimeException - */ - private 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:repo:enable first.'); - } - - $question = new ConfirmationQuestion( - sprintf('%s Run swissup:repo:enable now? [Y/n] ', $message), - true - ); - if (!$this->getHelper('question')->ask($input, $output, $question)) { - return false; - } - - return $this->getApplication() - ->find('swissup:repo:enable') - ->run(new ArrayInput([]), $output) === Cli::RETURN_SUCCESS; - } - /** * Check access key and packages availability before touching the store. * Much faster than "composer show --available". diff --git a/Console/Command/Installer/PackageUpdateCommand.php b/Console/Command/Installer/PackageUpdateCommand.php new file mode 100644 index 0000000..c09fb1c --- /dev/null +++ b/Console/Command/Installer/PackageUpdateCommand.php @@ -0,0 +1,111 @@ +setName('swissup:update') + ->setDescription('Update swissup packages using composer and run setup:upgrade') + ->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); + $args = $this->getUpdateArgs( + (bool) $input->getOption(self::INPUT_OPTION_WITH_DEPENDENCIES) + ); + + try { + if (!$this->getInstalledPackages()) { + $output->writeln('There are no swissup packages to update'); + return Cli::RETURN_SUCCESS; + } + + $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 boolean $withDependencies + * @return array + */ + private function getUpdateArgs($withDependencies) + { + $args = [ + 'update', + self::PACKAGES_PATTERN, + '--no-progress', + ]; + + if ($withDependencies) { + $args[] = '--with-dependencies'; + } + + if (!$this->composer->isDevInstalled()) { + $args[] = '--no-dev'; + } + + return $args; + } + + /** + * @return array Package name => version constraint + */ + private function getInstalledPackages() + { + $prefix = rtrim(self::PACKAGES_PATTERN, '*'); + + return array_filter( + $this->composer->getRequirements(), + function ($name) use ($prefix) { + return strpos(strtolower($name), $prefix) === 0; + }, + ARRAY_FILTER_USE_KEY + ); + } +} diff --git a/Model/Installer/DisabledModules.php b/Model/Installer/DisabledModules.php deleted file mode 100644 index 67d6cef..0000000 --- a/Model/Installer/DisabledModules.php +++ /dev/null @@ -1,60 +0,0 @@ -fullModuleList = $fullModuleList; - $this->moduleList = $moduleList; - $this->componentRegistrar = $componentRegistrar; - } - - /** - * @return string[] - */ - public function getNames() - { - return array_values( - array_diff($this->fullModuleList->getNames(), $this->moduleList->getNames()) - ); - } - - /** - * Declarative schema drops the tables of such modules during setup:upgrade: - * db_schema.xml is read for the enabled modules only, while - * db_schema_whitelist.json - the file that allows the drop - is read for all of them. - * - * @return string[] - */ - public function getNamesWithDbSchema() - { - $result = []; - - foreach ($this->getNames() as $name) { - $path = $this->componentRegistrar->getPath(ComponentRegistrar::MODULE, $name); - - if ($path && is_file($path . '/etc/db_schema_whitelist.json')) { - $result[] = $name; - } - } - - return $result; - } -} diff --git a/README.md b/README.md index 72fa95c..fe0f3b5 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ Command | Description **Packages** | `bin/magento swissup:require {package}` | Download package `bin/magento swissup:install {package}` | Run installer for downloaded package -`bin/magento swissup:remove {package}` | Remove package `bin/magento swissup:update` | Update `swissup/*` packages +`bin/magento swissup:remove {package}` | Remove package ## Popup Message Manager diff --git a/etc/di.xml b/etc/di.xml index 395e83a..9847fc9 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -10,6 +10,7 @@ Swissup\Core\Console\Command\Installer\RepoEnableCommand Swissup\Core\Console\Command\Installer\RepoDisableCommand Swissup\Core\Console\Command\Installer\PackageRequireCommand + Swissup\Core\Console\Command\Installer\PackageUpdateCommand Swissup\Core\Console\Command\Installer\PackageRemoveCommand Swissup\Core\Console\Command\Installer\AuthShowCommand Swissup\Core\Console\Command\Installer\AuthAddCommand From e4017ca5fa429e462a5b0d39657d32eaea16bd2e Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Fri, 18 Sep 2026 10:12:16 +0300 Subject: [PATCH 04/34] Array offset cannot be null --- Ui/DataProvider/ModuleListingDataProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Ui/DataProvider/ModuleListingDataProvider.php b/Ui/DataProvider/ModuleListingDataProvider.php index 6928126..7f655d3 100644 --- a/Ui/DataProvider/ModuleListingDataProvider.php +++ b/Ui/DataProvider/ModuleListingDataProvider.php @@ -86,7 +86,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); From e857e3712722119fdb92bfef96a0ccee864f20ff Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Fri, 18 Sep 2026 10:12:47 +0300 Subject: [PATCH 05/34] Remove useless comment --- Ui/DataProvider/ModuleListingDataProvider.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/Ui/DataProvider/ModuleListingDataProvider.php b/Ui/DataProvider/ModuleListingDataProvider.php index 7f655d3..27ce746 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']; From 48a0078f044ad5d3f61c4f9bbd5164e39bd5f040 Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Fri, 18 Sep 2026 10:20:49 +0300 Subject: [PATCH 06/34] Update command descriptions --- Console/Command/Installer/AuthAddCommand.php | 2 +- Console/Command/Installer/AuthShowCommand.php | 2 +- Console/Command/Installer/RepoDisableCommand.php | 2 +- Console/Command/Installer/RepoEnableCommand.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Console/Command/Installer/AuthAddCommand.php b/Console/Command/Installer/AuthAddCommand.php index d15ceab..a579145 100644 --- a/Console/Command/Installer/AuthAddCommand.php +++ b/Console/Command/Installer/AuthAddCommand.php @@ -23,7 +23,7 @@ public function __construct(ComposerRepository $repository) protected function configure() { $this->setName('swissup:auth:add') - ->setDescription('Add access key') + ->setDescription('Add access key to use swissup packages repository') ->addArgument(self::INPUT_ARGUMENT_KEY, InputArgument::REQUIRED, 'Access key'); parent::configure(); } diff --git a/Console/Command/Installer/AuthShowCommand.php b/Console/Command/Installer/AuthShowCommand.php index 3e8c57a..5870ab9 100644 --- a/Console/Command/Installer/AuthShowCommand.php +++ b/Console/Command/Installer/AuthShowCommand.php @@ -21,7 +21,7 @@ public function __construct(ComposerRepository $repository) protected function configure() { $this->setName('swissup:auth:show') - ->setDescription('Display your access keys'); + ->setDescription('Display access keys used to download swissup packages'); parent::configure(); } diff --git a/Console/Command/Installer/RepoDisableCommand.php b/Console/Command/Installer/RepoDisableCommand.php index 9c8e55a..610efc1 100644 --- a/Console/Command/Installer/RepoDisableCommand.php +++ b/Console/Command/Installer/RepoDisableCommand.php @@ -21,7 +21,7 @@ protected function configure() { $this->setName('swissup:repo:disable') ->setAliases(['swissup:channel:disable']) - ->setDescription('Remove swissuplabs repository from composer.json file'); + ->setDescription('Remove swissup packages repository from composer.json file'); parent::configure(); } diff --git a/Console/Command/Installer/RepoEnableCommand.php b/Console/Command/Installer/RepoEnableCommand.php index 0df5c6f..287d2a4 100644 --- a/Console/Command/Installer/RepoEnableCommand.php +++ b/Console/Command/Installer/RepoEnableCommand.php @@ -23,7 +23,7 @@ protected function configure() { $this->setName('swissup:repo:enable') ->setAliases(['swissup:channel:enable']) - ->setDescription('Add swissuplabs repository to composer.json file'); + ->setDescription('Add swissup packages repository to composer.json file'); parent::configure(); } From 69fcf1345993c391b811da1d3fffee7caec33181 Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Fri, 18 Sep 2026 10:31:08 +0300 Subject: [PATCH 07/34] Use `swissup.extra` params as primary source --- Model/ComponentList/Loader/Remote.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Model/ComponentList/Loader/Remote.php b/Model/ComponentList/Loader/Remote.php index fcb42de..e25428f 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', ]; } From 1e3a63c0b98557a439aacd23eac42b3b9f13ec86 Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Fri, 18 Sep 2026 10:56:09 +0300 Subject: [PATCH 08/34] Repo => Channel to match old command --- Console/Command/Installer/AuthAddCommand.php | 4 ++-- Console/Command/Installer/AuthRemoveCommand.php | 2 +- Console/Command/Installer/AuthShowCommand.php | 2 +- .../{RepoDisableCommand.php => ChannelDisableCommand.php} | 7 +++---- .../{RepoEnableCommand.php => ChannelEnableCommand.php} | 7 +++---- etc/di.xml | 4 ++-- 6 files changed, 12 insertions(+), 14 deletions(-) rename Console/Command/Installer/{RepoDisableCommand.php => ChannelDisableCommand.php} (85%) rename Console/Command/Installer/{RepoEnableCommand.php => ChannelEnableCommand.php} (95%) diff --git a/Console/Command/Installer/AuthAddCommand.php b/Console/Command/Installer/AuthAddCommand.php index a579145..4b42315 100644 --- a/Console/Command/Installer/AuthAddCommand.php +++ b/Console/Command/Installer/AuthAddCommand.php @@ -23,7 +23,7 @@ public function __construct(ComposerRepository $repository) protected function configure() { $this->setName('swissup:auth:add') - ->setDescription('Add access key to use swissup packages repository') + ->setDescription('Add SwissupLabs access key') ->addArgument(self::INPUT_ARGUMENT_KEY, InputArgument::REQUIRED, 'Access key'); parent::configure(); } @@ -46,7 +46,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (!$this->repository->isEnabled()) { $output->writeln( - 'Swissuplabs repository is not enabled. Run bin/magento swissup:repo:enable' + 'SwissupLabs repository is not enabled. Run bin/magento swissup:repo:enable' ); } } catch (\Exception $e) { diff --git a/Console/Command/Installer/AuthRemoveCommand.php b/Console/Command/Installer/AuthRemoveCommand.php index c337bf1..cfd6698 100644 --- a/Console/Command/Installer/AuthRemoveCommand.php +++ b/Console/Command/Installer/AuthRemoveCommand.php @@ -23,7 +23,7 @@ public function __construct(ComposerRepository $repository) protected function configure() { $this->setName('swissup:auth:remove') - ->setDescription('Remove access key') + ->setDescription('Remove SwissupLabs access key') ->addArgument(self::INPUT_ARGUMENT_KEY, InputArgument::REQUIRED, 'Access key'); parent::configure(); } diff --git a/Console/Command/Installer/AuthShowCommand.php b/Console/Command/Installer/AuthShowCommand.php index 5870ab9..cc509a5 100644 --- a/Console/Command/Installer/AuthShowCommand.php +++ b/Console/Command/Installer/AuthShowCommand.php @@ -21,7 +21,7 @@ public function __construct(ComposerRepository $repository) protected function configure() { $this->setName('swissup:auth:show') - ->setDescription('Display access keys used to download swissup packages'); + ->setDescription('Display SwissupLabs access keys currently in use'); parent::configure(); } diff --git a/Console/Command/Installer/RepoDisableCommand.php b/Console/Command/Installer/ChannelDisableCommand.php similarity index 85% rename from Console/Command/Installer/RepoDisableCommand.php rename to Console/Command/Installer/ChannelDisableCommand.php index 610efc1..183dbd7 100644 --- a/Console/Command/Installer/RepoDisableCommand.php +++ b/Console/Command/Installer/ChannelDisableCommand.php @@ -7,7 +7,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class RepoDisableCommand extends Command +class ChannelDisableCommand extends Command { private ComposerRepository $repository; @@ -19,9 +19,8 @@ public function __construct(ComposerRepository $repository) protected function configure() { - $this->setName('swissup:repo:disable') - ->setAliases(['swissup:channel:disable']) - ->setDescription('Remove swissup packages repository from composer.json file'); + $this->setName('swissup:channel:disable') + ->setDescription('Remove SwissupLabs repository from composer.json file'); parent::configure(); } diff --git a/Console/Command/Installer/RepoEnableCommand.php b/Console/Command/Installer/ChannelEnableCommand.php similarity index 95% rename from Console/Command/Installer/RepoEnableCommand.php rename to Console/Command/Installer/ChannelEnableCommand.php index 287d2a4..1478edf 100644 --- a/Console/Command/Installer/RepoEnableCommand.php +++ b/Console/Command/Installer/ChannelEnableCommand.php @@ -9,7 +9,7 @@ use Symfony\Component\Console\Question\ConfirmationQuestion; use Symfony\Component\Console\Question\Question; -class RepoEnableCommand extends Command +class ChannelEnableCommand extends Command { private ComposerRepository $repository; @@ -21,9 +21,8 @@ public function __construct(ComposerRepository $repository) protected function configure() { - $this->setName('swissup:repo:enable') - ->setAliases(['swissup:channel:enable']) - ->setDescription('Add swissup packages repository to composer.json file'); + $this->setName('swissup:channel:enable') + ->setDescription('Add SwissupLabs repository to composer.json file'); parent::configure(); } diff --git a/etc/di.xml b/etc/di.xml index 9847fc9..8c467d8 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -7,8 +7,8 @@ Swissup\Core\Console\Command\ModuleListCommand Swissup\Core\Console\Command\ThemeCreateCommand Swissup\Core\Console\Command\StoreCreateCommand - Swissup\Core\Console\Command\Installer\RepoEnableCommand - Swissup\Core\Console\Command\Installer\RepoDisableCommand + 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\PackageRemoveCommand From 9ffeb44b27047df95cd9f87dd6c6e4af3217381a Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Fri, 18 Sep 2026 11:18:33 +0300 Subject: [PATCH 09/34] WIP --- .../Installer/PackageAbstractCommand.php | 2 +- .../Installer/PackageRemoveCommand.php | 4 +-- .../Installer/PackageRequireCommand.php | 4 +-- .../Installer/PackageUpdateCommand.php | 4 +-- README.md | 26 +++++++++---------- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Console/Command/Installer/PackageAbstractCommand.php b/Console/Command/Installer/PackageAbstractCommand.php index 825a9a8..fb65ae8 100644 --- a/Console/Command/Installer/PackageAbstractCommand.php +++ b/Console/Command/Installer/PackageAbstractCommand.php @@ -124,7 +124,7 @@ protected function ensureRepositoryEnabled(InputInterface $input, OutputInterfac } $message = 'Access key is not found.'; } else { - $message = 'Swissuplabs repository is not enabled.'; + $message = 'SwissupLabs repository is not enabled.'; } if (!$input->isInteractive()) { diff --git a/Console/Command/Installer/PackageRemoveCommand.php b/Console/Command/Installer/PackageRemoveCommand.php index 7da72c0..1fc7f9b 100644 --- a/Console/Command/Installer/PackageRemoveCommand.php +++ b/Console/Command/Installer/PackageRemoveCommand.php @@ -11,8 +11,8 @@ class PackageRemoveCommand extends PackageAbstractCommand { protected function configure() { - $this->setName('swissup:remove') - ->setDescription('Remove swissup package(s) using composer and run setup:upgrade') + $this->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, diff --git a/Console/Command/Installer/PackageRequireCommand.php b/Console/Command/Installer/PackageRequireCommand.php index f06a02c..0bccf0b 100644 --- a/Console/Command/Installer/PackageRequireCommand.php +++ b/Console/Command/Installer/PackageRequireCommand.php @@ -11,8 +11,8 @@ class PackageRequireCommand extends PackageAbstractCommand { protected function configure() { - $this->setName('swissup:require') - ->setDescription('Download swissup package(s) using composer and run setup:upgrade') + $this->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, diff --git a/Console/Command/Installer/PackageUpdateCommand.php b/Console/Command/Installer/PackageUpdateCommand.php index c09fb1c..be8e223 100644 --- a/Console/Command/Installer/PackageUpdateCommand.php +++ b/Console/Command/Installer/PackageUpdateCommand.php @@ -13,8 +13,8 @@ class PackageUpdateCommand extends PackageAbstractCommand protected function configure() { - $this->setName('swissup:update') - ->setDescription('Update swissup packages using composer and run setup:upgrade') + $this->setName('swissup:package:update') + ->setDescription('Update SwissupLabs packages using composer and run setup:upgrade') ->addOption( self::INPUT_OPTION_DRY_RUN, null, diff --git a/README.md b/README.md index fe0f3b5..d7a9a02 100644 --- a/README.md +++ b/README.md @@ -14,19 +14,19 @@ bin/magento setup:upgrade Aavailable commands -Command | Description -----------------------------------------|--------------------------------------- -`bin/magento swissup:repo:enable` | Add repo to composer.json file -`bin/magento swissup:repo:disable` | Remove repo from composer.json file -**Authorization** | -`bin/magento swissup:auth:add {key}` | Add auth key -`bin/magento swissup:auth:remove {key}` | Remove auth key -`bin/magento swissup:auth:show` | Display auth keys info -**Packages** | -`bin/magento swissup:require {package}` | Download package -`bin/magento swissup:install {package}` | Run installer for downloaded package -`bin/magento swissup:update` | Update `swissup/*` packages -`bin/magento swissup:remove {package}` | Remove package +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:remove {key}` | Remove SwissupLabs access key +`bin/magento swissup:auth:show` | Display SwissupLabs access keys 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 From 553041afac9c46d4d37a07f75631af716a8130cb Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Fri, 18 Sep 2026 11:30:52 +0300 Subject: [PATCH 10/34] Allow passing package names to swissup:package:update Co-Authored-By: Claude Opus 5 --- .../Installer/PackageRemoveCommand.php | 2 +- .../Installer/PackageUpdateCommand.php | 77 +++++++++++++++---- composer.json | 5 +- 3 files changed, 67 insertions(+), 17 deletions(-) diff --git a/Console/Command/Installer/PackageRemoveCommand.php b/Console/Command/Installer/PackageRemoveCommand.php index 1fc7f9b..43f81c6 100644 --- a/Console/Command/Installer/PackageRemoveCommand.php +++ b/Console/Command/Installer/PackageRemoveCommand.php @@ -94,7 +94,7 @@ private function validatePackages(array $packages) foreach ($packages as $package) { $name = $this->getPackageName($package); - if (strpos($name, 'swissup/') !== 0) { + 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 diff --git a/Console/Command/Installer/PackageUpdateCommand.php b/Console/Command/Installer/PackageUpdateCommand.php index be8e223..d2c219f 100644 --- a/Console/Command/Installer/PackageUpdateCommand.php +++ b/Console/Command/Installer/PackageUpdateCommand.php @@ -2,19 +2,24 @@ namespace Swissup\Core\Console\Command\Installer; use Magento\Framework\Console\Cli; +use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; class PackageUpdateCommand extends PackageAbstractCommand { - const PACKAGES_PATTERN = 'swissup/*'; const INPUT_OPTION_WITH_DEPENDENCIES = 'with-dependencies'; protected function configure() { $this->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, @@ -33,16 +38,22 @@ protected function configure() protected function execute(InputInterface $input, OutputInterface $output): int { $isDryRun = (bool) $input->getOption(self::INPUT_OPTION_DRY_RUN); - $args = $this->getUpdateArgs( - (bool) $input->getOption(self::INPUT_OPTION_WITH_DEPENDENCIES) - ); try { - if (!$this->getInstalledPackages()) { + $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', @@ -71,16 +82,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int * --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($withDependencies) + private function getUpdateArgs(array $packages, $withDependencies) { - $args = [ - 'update', - self::PACKAGES_PATTERN, - '--no-progress', - ]; + $args = array_merge(['update'], $packages, ['--no-progress']); if ($withDependencies) { $args[] = '--with-dependencies'; @@ -93,17 +101,56 @@ private function getUpdateArgs($withDependencies) 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() { - $prefix = rtrim(self::PACKAGES_PATTERN, '*'); - return array_filter( $this->composer->getRequirements(), - function ($name) use ($prefix) { - return strpos(strtolower($name), $prefix) === 0; + function ($name) { + return str_starts_with(strtolower($name), 'swissup/'); }, ARRAY_FILTER_USE_KEY ); diff --git a/composer.json b/composer.json index f6a117c..f85c547 100644 --- a/composer.json +++ b/composer.json @@ -1,9 +1,12 @@ { "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" + }, "autoload": { "files": [ "registration.php" ], "psr-4": { From d6d0174537cee3e60fd783c0f43641a27fa3ae0c Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Fri, 18 Sep 2026 11:55:56 +0300 Subject: [PATCH 11/34] WIP --- Console/Command/Installer/AuthAddCommand.php | 2 +- Console/Command/Installer/PackageAbstractCommand.php | 8 ++++---- Console/Command/Installer/PackageRequireCommand.php | 2 +- Model/Installer/ComposerRepository.php | 3 --- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/Console/Command/Installer/AuthAddCommand.php b/Console/Command/Installer/AuthAddCommand.php index 4b42315..3876a70 100644 --- a/Console/Command/Installer/AuthAddCommand.php +++ b/Console/Command/Installer/AuthAddCommand.php @@ -46,7 +46,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (!$this->repository->isEnabled()) { $output->writeln( - 'SwissupLabs repository is not enabled. Run bin/magento swissup:repo:enable' + 'SwissupLabs repository is not enabled. Run bin/magento swissup:channel:enable' ); } } catch (\Exception $e) { diff --git a/Console/Command/Installer/PackageAbstractCommand.php b/Console/Command/Installer/PackageAbstractCommand.php index fb65ae8..f5bc74f 100644 --- a/Console/Command/Installer/PackageAbstractCommand.php +++ b/Console/Command/Installer/PackageAbstractCommand.php @@ -108,7 +108,7 @@ protected function validate(array $manualCommands, $isDryRun) } /** - * Offer to run swissup:repo:enable when repository or access key is missing + * Offer to run swissup:channel:enable when repository or access key is missing * * @param InputInterface $input * @param OutputInterface $output @@ -128,11 +128,11 @@ protected function ensureRepositoryEnabled(InputInterface $input, OutputInterfac } if (!$input->isInteractive()) { - throw new \RuntimeException($message . ' Run bin/magento swissup:repo:enable first.'); + throw new \RuntimeException($message . ' Run bin/magento swissup:channel:enable first.'); } $question = new ConfirmationQuestion( - sprintf('%s Run swissup:repo:enable now? [Y/n] ', $message), + sprintf('%s Run swissup:channel:enable now? [Y/n] ', $message), true ); if (!$this->getHelper('question')->ask($input, $output, $question)) { @@ -140,7 +140,7 @@ protected function ensureRepositoryEnabled(InputInterface $input, OutputInterfac } return $this->getApplication() - ->find('swissup:repo:enable') + ->find('swissup:channel:enable') ->run(new ArrayInput([]), $output) === Cli::RETURN_SUCCESS; } diff --git a/Console/Command/Installer/PackageRequireCommand.php b/Console/Command/Installer/PackageRequireCommand.php index 0bccf0b..7897d54 100644 --- a/Console/Command/Installer/PackageRequireCommand.php +++ b/Console/Command/Installer/PackageRequireCommand.php @@ -94,7 +94,7 @@ 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:repo:enable first.' + 'Access key is not found. Run bin/magento swissup:channel:enable first.' ); } diff --git a/Model/Installer/ComposerRepository.php b/Model/Installer/ComposerRepository.php index 7a54468..d4d91c0 100644 --- a/Model/Installer/ComposerRepository.php +++ b/Model/Installer/ComposerRepository.php @@ -303,9 +303,6 @@ public function getPackages($username, $password) */ private function fetch($url, $username, $password) { - (new \Monolog\Logger('custom')) - ->pushHandler((new \Monolog\Handler\StreamHandler(BP . '/var/log/custom.log'))->setFormatter(new \Monolog\Formatter\LineFormatter(null, null, true, true))) - ->debug(print_r(__METHOD__, true)); $client = $this->curlFactory->create(); $client->setOption(CURLOPT_FOLLOWLOCATION, true); $client->setOption(CURLOPT_MAXREDIRS, 5); From 42c579ce5d30440cffecbfbd1da831a3a6b0cc68 Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Fri, 18 Sep 2026 13:42:36 +0300 Subject: [PATCH 12/34] Copy Marketplace Installer engine --- Installer/Command/CategoryUpdate.php | 61 ++++ Installer/Command/CmsBlock.php | 116 ++++++ Installer/Command/CmsPage.php | 189 ++++++++++ Installer/Command/Config.php | 109 ++++++ Installer/Command/CopyMediaDir.php | 63 ++++ Installer/Command/Product.php | 19 + Installer/Command/ProductAttribute.php | 112 ++++++ Installer/Command/ProductCollection.php | 133 +++++++ Installer/Command/Unpack.php | 49 +++ Installer/Command/Widget.php | 165 +++++++++ Installer/ConfigReader.php | 449 ++++++++++++++++++++++++ Installer/Helper/Arr.php | 11 + Installer/Helper/Collection.php | 67 ++++ Installer/Helper/Renderer.php | 27 ++ Installer/Helper/Request.php | 11 + Installer/Helper/Serializer.php | 30 ++ Installer/Helper/Text.php | 15 + Installer/Helper/Theme.php | 41 +++ Installer/Installer.php | 261 ++++++++++++++ Installer/Request.php | 13 + Model/Traits/LoggerAware.php | 36 ++ 21 files changed, 1977 insertions(+) create mode 100644 Installer/Command/CategoryUpdate.php create mode 100644 Installer/Command/CmsBlock.php create mode 100644 Installer/Command/CmsPage.php create mode 100644 Installer/Command/Config.php create mode 100644 Installer/Command/CopyMediaDir.php create mode 100644 Installer/Command/Product.php create mode 100644 Installer/Command/ProductAttribute.php create mode 100644 Installer/Command/ProductCollection.php create mode 100644 Installer/Command/Unpack.php create mode 100644 Installer/Command/Widget.php create mode 100644 Installer/ConfigReader.php create mode 100644 Installer/Helper/Arr.php create mode 100644 Installer/Helper/Collection.php create mode 100644 Installer/Helper/Renderer.php create mode 100644 Installer/Helper/Request.php create mode 100644 Installer/Helper/Serializer.php create mode 100644 Installer/Helper/Text.php create mode 100644 Installer/Helper/Theme.php create mode 100644 Installer/Installer.php create mode 100644 Installer/Request.php create mode 100644 Model/Traits/LoggerAware.php diff --git a/Installer/Command/CategoryUpdate.php b/Installer/Command/CategoryUpdate.php new file mode 100644 index 0000000..77cb18a --- /dev/null +++ b/Installer/Command/CategoryUpdate.php @@ -0,0 +1,61 @@ +collectionHelper = $collectionHelper; + } + + /** + * @param Request $request + * @return void + */ + public function execute(Request $request) + { + $this->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..de1de65 --- /dev/null +++ b/Installer/Command/CmsBlock.php @@ -0,0 +1,116 @@ +blockFactory = $blockFactory; + $this->collectionFactory = $collectionFactory; + $this->localeDate = $localeDate; + $this->storeManager = $storeManager; + } + + /** + * @param Request $request + * @return void + */ + public function execute(Request $request) + { + $this->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..7f45aea --- /dev/null +++ b/Installer/Command/CmsPage.php @@ -0,0 +1,189 @@ +pageFactory = $pageFactory; + $this->collectionFactory = $collectionFactory; + $this->localeDate = $localeDate; + $this->storeManager = $storeManager; + $this->urlRewriteCollectionFactory = $urlRewriteCollectionFactory; + } + + /** + * @param Request $request + * @return void + */ + public function execute(Request $request) + { + $idsToInstall = array_flip($request->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..7c0ba4a --- /dev/null +++ b/Installer/Command/Config.php @@ -0,0 +1,109 @@ +scopeConfig = $scopeConfig; + $this->configWriter = $configWriter; + } + + /** + * @param Request $request + * @return void + */ + public function execute(Request $request) + { + $this->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..c4391f9 --- /dev/null +++ b/Installer/Command/CopyMediaDir.php @@ -0,0 +1,63 @@ +filesystem = $filesystem; + } + + /** + * @param Request $request + * @return void + */ + public function execute(Request $request) + { + $this->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..a831d65 --- /dev/null +++ b/Installer/Command/Product.php @@ -0,0 +1,19 @@ +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..7b175a5 --- /dev/null +++ b/Installer/Command/ProductAttribute.php @@ -0,0 +1,112 @@ +attributeFactory = $attributeFactory; + $this->productHelper = $productHelper; + $this->eavEntityFactory = $eavEntityFactory; + $this->attributeSetCollectionFactory = $attributeSetCollectionFactory; + } + + /** + * @param Request $request + * @return void + */ + public function execute(Request $request) + { + $this->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..8fac2a3 --- /dev/null +++ b/Installer/Command/ProductCollection.php @@ -0,0 +1,133 @@ +attributeCollectionFactory = $attributeCollectionFactory; + $this->productCollectionFactory = $productCollectionFactory; + $this->catalogProductVisibility = $catalogProductVisibility; + $this->localeDate = $localeDate; + $this->storeManager = $storeManager; + } + + /** + * @param Request $request + * @return void + */ + public function execute(Request $request) + { + $this->logger->info('Product Collection: Prepare collections'); + + $data = $request->getParams(); + $visibility = $this->catalogProductVisibility->getVisibleInCatalogIds(); + $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; + $collection->addAttributeToFilter($attribute, 1); + break; + case 'date': + $value = $this->localeDate->date()->format('Y-m-d H:i:s'); + $collection->addAttributeToFilter( + $attribute, + [ + [ + 'date' => true, + 'to' => $value + ] + ] + ); + break; + } + + 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(), + (int) in_array(0, $request->getStoreIds()), // value + \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..1d23277 --- /dev/null +++ b/Installer/Command/Unpack.php @@ -0,0 +1,49 @@ +archiver = $archiver; + $this->ioFile = $ioFile; + } + + /** + * @param Request $request + * @return void + */ + public function execute(Request $request) + { + $this->logger->info('Unpack'); + $params = $request->getParams(); + $destanation = $params['destination']; + $this->ioFile->checkAndCreateFolder($destanation); + $archive = $params['archive']; + $this->archiver->unpack($archive, $destanation); + } +} diff --git a/Installer/Command/Widget.php b/Installer/Command/Widget.php new file mode 100644 index 0000000..8e475a9 --- /dev/null +++ b/Installer/Command/Widget.php @@ -0,0 +1,165 @@ +storeManager = $storeManager; + $this->widgetFactory = $widgetFactory; + $this->collectionFactory = $collectionFactory; + } + + /** + * @param Request $request + * @return void + */ + public function execute(Request $request) + { + $this->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) + { + 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..35d816a --- /dev/null +++ b/Installer/ConfigReader.php @@ -0,0 +1,449 @@ +componentRegistrar = $componentRegistrar; + $this->readDirFactory = $readDirFactory; + $this->moduleManager = $moduleManager; + $this->objectManager = $objectManager; + $this->configFactory = $configFactory; + } + + /** + * @param array $packages + * @return boolean + */ + public function hasConfig($packages) + { + foreach ($this->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 (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 = $objectManager; + } + + /** + * @param string $class + * @param array $filters + * @return \Magento\Framework\Data\Collection + */ + protected function prepareCollection($class, array $filters = []) + { + $collection = $this->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..38059b0 --- /dev/null +++ b/Installer/Helper/Renderer.php @@ -0,0 +1,27 @@ +jsonSerializer = $jsonSerializer; + } + + /** + * @param array $request + * @param array $value + * @return string + */ + public function serialize(array $request, $value) + { + return $this->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 @@ +collectionFactory = $collectionFactory; + } + + /** + * @param array $request + * @param string $path + * @return int + */ + public function getId(array $request, $path) + { + if (!isset($this->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..aaecb27 --- /dev/null +++ b/Installer/Installer.php @@ -0,0 +1,261 @@ +cache = $cache; + $this->appState = $appState; + $this->objectManager = $objectManager; + $this->configReader = $configReader; + $this->requestFactory = $requestFactory; + } + + public function setRunOnlyIfRequired($flag) + { + $this->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 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/Traits/LoggerAware.php b/Model/Traits/LoggerAware.php new file mode 100644 index 0000000..58e9d40 --- /dev/null +++ b/Model/Traits/LoggerAware.php @@ -0,0 +1,36 @@ +logger = $logger; + + return $this; + } + + /** + * @return LoggerInterface + */ + public function getLogger() + { + if (!$this->logger) { + $this->logger = new NullLogger(); + } + + return $this->logger; + } +} From c09401604fbaa54ea0adf1472e75349163858e35 Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Fri, 18 Sep 2026 15:43:19 +0300 Subject: [PATCH 13/34] Installer command --- .../Installer/PackageInstallCommand.php | 333 ++++++++++++++++++ etc/di.xml | 1 + 2 files changed, 334 insertions(+) create mode 100644 Console/Command/Installer/PackageInstallCommand.php diff --git a/Console/Command/Installer/PackageInstallCommand.php b/Console/Command/Installer/PackageInstallCommand.php new file mode 100644 index 0000000..47d2da9 --- /dev/null +++ b/Console/Command/Installer/PackageInstallCommand.php @@ -0,0 +1,333 @@ +storeManager = $storeManager; + $this->questionFactory = $questionFactory; + $this->choiceQuestionFactory = $choiceQuestionFactory; + $this->questionHelper = $questionHelper; + $this->installer = $installer; + + parent::__construct(); + } + + /** + * Initializes the command after the input has been bound and before the input + * is validated. + */ + protected function initialize(InputInterface $input, OutputInterface $output): void + { + $this->input = $input; + $this->output = $output; + $this->logger = new ConsoleLogger($output); + + $output->setVerbosity(OutputInterface::VERBOSITY_DEBUG); + + 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' + ); + + 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)) { + $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) + ->run($packages, array_merge($formData, [ + 'store_id' => $storeIds, + 'packages' => $packages, + ])); + + $output->writeln('Done.'); + + return \Magento\Framework\Console\Cli::RETURN_SUCCESS; + } + + 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/etc/di.xml b/etc/di.xml index 8c467d8..9762d16 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -11,6 +11,7 @@ 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\AuthAddCommand From 8d382265dfdcf58abf7f3f6747f83a638bdc85b7 Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Fri, 18 Sep 2026 15:59:00 +0300 Subject: [PATCH 14/34] Require and then install in one shot --- .../Installer/PackageInstallCommand.php | 142 +++++++++++++++++- 1 file changed, 141 insertions(+), 1 deletion(-) diff --git a/Console/Command/Installer/PackageInstallCommand.php b/Console/Command/Installer/PackageInstallCommand.php index 47d2da9..ad3390b 100644 --- a/Console/Command/Installer/PackageInstallCommand.php +++ b/Console/Command/Installer/PackageInstallCommand.php @@ -2,17 +2,21 @@ namespace Swissup\Core\Console\Command\Installer; +use Magento\Framework\Component\ComponentRegistrar; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Logger\ConsoleLogger; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; class PackageInstallCommand extends Command { const INPUT_KEY_STORE = 'store'; + const INPUT_KEY_NO_DOWNLOAD = 'no-download'; /** * @var InputInterface @@ -54,25 +58,57 @@ class PackageInstallCommand extends Command */ protected $installer; + /** + * @var \Magento\Framework\Component\ComponentRegistrarInterface + */ + protected $componentRegistrar; + + /** + * @var \Magento\Theme\Model\Theme\ThemePackageInfo + */ + protected $themePackageInfo; + + /** + * @var \Swissup\Core\Helper\Component + */ + protected $componentHelper; + + /** + * @var \Swissup\Core\Model\Installer\Process + */ + protected $process; + /** * @param \Magento\Store\Model\StoreManagerInterface $storeManager * @param \Symfony\Component\Console\Question\QuestionFactory $questionFactory * @param \Symfony\Component\Console\Question\ChoiceQuestionFactory $choiceQuestionFactory * @param \Symfony\Component\Console\Helper\QuestionHelper $questionHelper * @param \Swissup\Core\Installer\Installer $installer + * @param \Magento\Framework\Component\ComponentRegistrarInterface $componentRegistrar + * @param \Magento\Theme\Model\Theme\ThemePackageInfo $themePackageInfo + * @param \Swissup\Core\Helper\Component $componentHelper + * @param \Swissup\Core\Model\Installer\Process $process */ public function __construct( \Magento\Store\Model\StoreManagerInterface $storeManager, \Symfony\Component\Console\Question\QuestionFactory $questionFactory, \Symfony\Component\Console\Question\ChoiceQuestionFactory $choiceQuestionFactory, \Symfony\Component\Console\Helper\QuestionHelper $questionHelper, - \Swissup\Core\Installer\Installer $installer + \Swissup\Core\Installer\Installer $installer, + \Magento\Framework\Component\ComponentRegistrarInterface $componentRegistrar, + \Magento\Theme\Model\Theme\ThemePackageInfo $themePackageInfo, + \Swissup\Core\Helper\Component $componentHelper, + \Swissup\Core\Model\Installer\Process $process ) { $this->storeManager = $storeManager; $this->questionFactory = $questionFactory; $this->choiceQuestionFactory = $choiceQuestionFactory; $this->questionHelper = $questionHelper; $this->installer = $installer; + $this->componentRegistrar = $componentRegistrar; + $this->themePackageInfo = $themePackageInfo; + $this->componentHelper = $componentHelper; + $this->process = $process; parent::__construct(); } @@ -123,6 +159,12 @@ protected function configure(): void 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(); } @@ -183,6 +225,12 @@ 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; } @@ -228,6 +276,98 @@ protected function execute(InputInterface $input, OutputInterface $output): int 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(), + ['--' . 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); From 6623443efdb832ad371280e6eaaf451b911ab865 Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Fri, 18 Sep 2026 16:36:42 +0300 Subject: [PATCH 15/34] Use constructor property promotion in installer classes Co-Authored-By: Claude Opus 5 --- Console/Command/Installer/AuthAddCommand.php | 5 +- .../Command/Installer/AuthRemoveCommand.php | 5 +- Console/Command/Installer/AuthShowCommand.php | 5 +- .../Installer/ChannelDisableCommand.php | 5 +- .../Installer/ChannelEnableCommand.php | 5 +- .../Installer/PackageAbstractCommand.php | 33 ++------ .../Installer/PackageInstallCommand.php | 84 ++----------------- Installer/Command/CategoryUpdate.php | 8 +- Installer/Command/CmsBlock.php | 38 +-------- Installer/Command/CmsPage.php | 46 ++-------- Installer/Command/Config.php | 20 +---- Installer/Command/CopyMediaDir.php | 12 +-- Installer/Command/ProductAttribute.php | 38 +-------- Installer/Command/ProductCollection.php | 47 ++--------- Installer/Command/Unpack.php | 20 +---- Installer/Command/Widget.php | 29 +------ Installer/ConfigReader.php | 47 ++--------- Installer/Helper/Collection.php | 11 +-- Installer/Helper/Serializer.php | 11 +-- Installer/Helper/Theme.php | 11 +-- Installer/Installer.php | 47 ++--------- Model/Installer/Composer.php | 9 +- Model/Installer/ComposerRepository.php | 16 +--- 23 files changed, 68 insertions(+), 484 deletions(-) diff --git a/Console/Command/Installer/AuthAddCommand.php b/Console/Command/Installer/AuthAddCommand.php index 3876a70..a6050a2 100644 --- a/Console/Command/Installer/AuthAddCommand.php +++ b/Console/Command/Installer/AuthAddCommand.php @@ -12,11 +12,8 @@ class AuthAddCommand extends Command { const INPUT_ARGUMENT_KEY = 'key'; - private ComposerRepository $repository; - - public function __construct(ComposerRepository $repository) + public function __construct(private ComposerRepository $repository) { - $this->repository = $repository; parent::__construct(); } diff --git a/Console/Command/Installer/AuthRemoveCommand.php b/Console/Command/Installer/AuthRemoveCommand.php index cfd6698..0815adb 100644 --- a/Console/Command/Installer/AuthRemoveCommand.php +++ b/Console/Command/Installer/AuthRemoveCommand.php @@ -12,11 +12,8 @@ class AuthRemoveCommand extends Command { const INPUT_ARGUMENT_KEY = 'key'; - private ComposerRepository $repository; - - public function __construct(ComposerRepository $repository) + public function __construct(private ComposerRepository $repository) { - $this->repository = $repository; parent::__construct(); } diff --git a/Console/Command/Installer/AuthShowCommand.php b/Console/Command/Installer/AuthShowCommand.php index cc509a5..7e30f48 100644 --- a/Console/Command/Installer/AuthShowCommand.php +++ b/Console/Command/Installer/AuthShowCommand.php @@ -10,11 +10,8 @@ class AuthShowCommand extends Command { - private ComposerRepository $repository; - - public function __construct(ComposerRepository $repository) + public function __construct(private ComposerRepository $repository) { - $this->repository = $repository; parent::__construct(); } diff --git a/Console/Command/Installer/ChannelDisableCommand.php b/Console/Command/Installer/ChannelDisableCommand.php index 183dbd7..0427b53 100644 --- a/Console/Command/Installer/ChannelDisableCommand.php +++ b/Console/Command/Installer/ChannelDisableCommand.php @@ -9,11 +9,8 @@ class ChannelDisableCommand extends Command { - private ComposerRepository $repository; - - public function __construct(ComposerRepository $repository) + public function __construct(private ComposerRepository $repository) { - $this->repository = $repository; parent::__construct(); } diff --git a/Console/Command/Installer/ChannelEnableCommand.php b/Console/Command/Installer/ChannelEnableCommand.php index 1478edf..098efb2 100644 --- a/Console/Command/Installer/ChannelEnableCommand.php +++ b/Console/Command/Installer/ChannelEnableCommand.php @@ -11,11 +11,8 @@ class ChannelEnableCommand extends Command { - private ComposerRepository $repository; - - public function __construct(ComposerRepository $repository) + public function __construct(private ComposerRepository $repository) { - $this->repository = $repository; parent::__construct(); } diff --git a/Console/Command/Installer/PackageAbstractCommand.php b/Console/Command/Installer/PackageAbstractCommand.php index f5bc74f..b8fdc69 100644 --- a/Console/Command/Installer/PackageAbstractCommand.php +++ b/Console/Command/Installer/PackageAbstractCommand.php @@ -22,37 +22,20 @@ abstract class PackageAbstractCommand extends Command const INPUT_ARGUMENT_PACKAGES = 'packages'; const INPUT_OPTION_DRY_RUN = 'dry-run'; - protected Composer $composer; - protected ComposerRepository $repository; - protected Process $process; - protected State $appState; - protected MaintenanceMode $maintenanceMode; - protected CleanupFiles $cleanupFiles; - protected CacheManager $cacheManager; - protected DirectoryList $directoryList; - /** * Dependencies are injected (not proxied) on purpose: * they must be loaded before composer changes the files. */ public function __construct( - Composer $composer, - ComposerRepository $repository, - Process $process, - State $appState, - MaintenanceMode $maintenanceMode, - CleanupFiles $cleanupFiles, - CacheManager $cacheManager, - DirectoryList $directoryList + protected Composer $composer, + protected ComposerRepository $repository, + protected Process $process, + protected State $appState, + protected MaintenanceMode $maintenanceMode, + protected CleanupFiles $cleanupFiles, + protected CacheManager $cacheManager, + protected DirectoryList $directoryList ) { - $this->directoryList = $directoryList; - $this->composer = $composer; - $this->repository = $repository; - $this->process = $process; - $this->appState = $appState; - $this->maintenanceMode = $maintenanceMode; - $this->cleanupFiles = $cleanupFiles; - $this->cacheManager = $cacheManager; parent::__construct(); } diff --git a/Console/Command/Installer/PackageInstallCommand.php b/Console/Command/Installer/PackageInstallCommand.php index ad3390b..7e455ad 100644 --- a/Console/Command/Installer/PackageInstallCommand.php +++ b/Console/Command/Installer/PackageInstallCommand.php @@ -33,83 +33,17 @@ class PackageInstallCommand extends Command */ protected $logger; - /** - * @var \Magento\Store\Model\StoreManagerInterface - */ - protected $storeManager; - - /** - * @var \Symfony\Component\Console\Question\QuestionFactory - */ - protected $questionFactory; - - /** - * @var \Symfony\Component\Console\Question\ChoiceQuestionFactory - */ - protected $choiceQuestionFactory; - - /** - * @var \Symfony\Component\Console\Helper\QuestionHelper - */ - protected $questionHelper; - - /** - * @var \Swissup\Core\Installer\Installer - */ - protected $installer; - - /** - * @var \Magento\Framework\Component\ComponentRegistrarInterface - */ - protected $componentRegistrar; - - /** - * @var \Magento\Theme\Model\Theme\ThemePackageInfo - */ - protected $themePackageInfo; - - /** - * @var \Swissup\Core\Helper\Component - */ - protected $componentHelper; - - /** - * @var \Swissup\Core\Model\Installer\Process - */ - protected $process; - - /** - * @param \Magento\Store\Model\StoreManagerInterface $storeManager - * @param \Symfony\Component\Console\Question\QuestionFactory $questionFactory - * @param \Symfony\Component\Console\Question\ChoiceQuestionFactory $choiceQuestionFactory - * @param \Symfony\Component\Console\Helper\QuestionHelper $questionHelper - * @param \Swissup\Core\Installer\Installer $installer - * @param \Magento\Framework\Component\ComponentRegistrarInterface $componentRegistrar - * @param \Magento\Theme\Model\Theme\ThemePackageInfo $themePackageInfo - * @param \Swissup\Core\Helper\Component $componentHelper - * @param \Swissup\Core\Model\Installer\Process $process - */ public function __construct( - \Magento\Store\Model\StoreManagerInterface $storeManager, - \Symfony\Component\Console\Question\QuestionFactory $questionFactory, - \Symfony\Component\Console\Question\ChoiceQuestionFactory $choiceQuestionFactory, - \Symfony\Component\Console\Helper\QuestionHelper $questionHelper, - \Swissup\Core\Installer\Installer $installer, - \Magento\Framework\Component\ComponentRegistrarInterface $componentRegistrar, - \Magento\Theme\Model\Theme\ThemePackageInfo $themePackageInfo, - \Swissup\Core\Helper\Component $componentHelper, - \Swissup\Core\Model\Installer\Process $process + protected \Magento\Store\Model\StoreManagerInterface $storeManager, + protected \Symfony\Component\Console\Question\QuestionFactory $questionFactory, + protected \Symfony\Component\Console\Question\ChoiceQuestionFactory $choiceQuestionFactory, + protected \Symfony\Component\Console\Helper\QuestionHelper $questionHelper, + protected \Swissup\Core\Installer\Installer $installer, + protected \Magento\Framework\Component\ComponentRegistrarInterface $componentRegistrar, + protected \Magento\Theme\Model\Theme\ThemePackageInfo $themePackageInfo, + protected \Swissup\Core\Helper\Component $componentHelper, + protected \Swissup\Core\Model\Installer\Process $process ) { - $this->storeManager = $storeManager; - $this->questionFactory = $questionFactory; - $this->choiceQuestionFactory = $choiceQuestionFactory; - $this->questionHelper = $questionHelper; - $this->installer = $installer; - $this->componentRegistrar = $componentRegistrar; - $this->themePackageInfo = $themePackageInfo; - $this->componentHelper = $componentHelper; - $this->process = $process; - parent::__construct(); } diff --git a/Installer/Command/CategoryUpdate.php b/Installer/Command/CategoryUpdate.php index 77cb18a..1730ebb 100644 --- a/Installer/Command/CategoryUpdate.php +++ b/Installer/Command/CategoryUpdate.php @@ -10,15 +10,9 @@ class CategoryUpdate { use LoggerAware; - /** - * @var \Swissup\Core\Installer\Helper\Collection - */ - private $collectionHelper; - public function __construct( - \Swissup\Core\Installer\Helper\Collection $collectionHelper + private \Swissup\Core\Installer\Helper\Collection $collectionHelper ) { - $this->collectionHelper = $collectionHelper; } /** diff --git a/Installer/Command/CmsBlock.php b/Installer/Command/CmsBlock.php index de1de65..b80814e 100644 --- a/Installer/Command/CmsBlock.php +++ b/Installer/Command/CmsBlock.php @@ -9,42 +9,12 @@ class CmsBlock { use LoggerAware; - /** - * @var \Magento\Cms\Model\BlockFactory - */ - private $blockFactory; - - /** - * @var \Magento\Cms\Model\ResourceModel\Block\CollectionFactory - */ - private $collectionFactory; - - /** - * @var \Magento\Framework\Stdlib\DateTime\TimezoneInterface - */ - private $localeDate; - - /** - * @var \Magento\Store\Model\StoreManagerInterface - */ - private $storeManager; - - /** - * @param \Magento\Cms\Model\BlockFactory $blockFactory - * @param \Magento\Cms\Model\ResourceModel\Block\CollectionFactory $collectionFactory - * @param \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate - * @param \Magento\Store\Model\StoreManagerInterface $storeManager - */ public function __construct( - \Magento\Cms\Model\BlockFactory $blockFactory, - \Magento\Cms\Model\ResourceModel\Block\CollectionFactory $collectionFactory, - \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate, - \Magento\Store\Model\StoreManagerInterface $storeManager + private \Magento\Cms\Model\BlockFactory $blockFactory, + private \Magento\Cms\Model\ResourceModel\Block\CollectionFactory $collectionFactory, + private \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate, + private \Magento\Store\Model\StoreManagerInterface $storeManager ) { - $this->blockFactory = $blockFactory; - $this->collectionFactory = $collectionFactory; - $this->localeDate = $localeDate; - $this->storeManager = $storeManager; } /** diff --git a/Installer/Command/CmsPage.php b/Installer/Command/CmsPage.php index 7f45aea..a8d3f7d 100644 --- a/Installer/Command/CmsPage.php +++ b/Installer/Command/CmsPage.php @@ -9,49 +9,13 @@ class CmsPage { use LoggerAware; - /** - * @var \Magento\Cms\Model\PageFactory - */ - private $pageFactory; - - /** - * @var \Magento\Cms\Model\ResourceModel\Page\CollectionFactory - */ - private $collectionFactory; - - /** - * @var \Magento\Framework\Stdlib\DateTime\TimezoneInterface - */ - private $localeDate; - - /** - * @var \Magento\Store\Model\StoreManagerInterface - */ - private $storeManager; - - /** - * @var \Magento\UrlRewrite\Model\ResourceModel\UrlRewriteCollectionFactory - */ - private $urlRewriteCollectionFactory; - - /** - * @param \Magento\Cms\Model\PageFactory $pageFactory - * @param \Magento\Cms\Model\ResourceModel\Page\CollectionFactory $collectionFactory - * @param \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate - * @param \Magento\Store\Model\StoreManagerInterface $storeManager - */ public function __construct( - \Magento\Cms\Model\PageFactory $pageFactory, - \Magento\Cms\Model\ResourceModel\Page\CollectionFactory $collectionFactory, - \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate, - \Magento\Store\Model\StoreManagerInterface $storeManager, - \Magento\UrlRewrite\Model\ResourceModel\UrlRewriteCollectionFactory $urlRewriteCollectionFactory + private \Magento\Cms\Model\PageFactory $pageFactory, + private \Magento\Cms\Model\ResourceModel\Page\CollectionFactory $collectionFactory, + private \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate, + private \Magento\Store\Model\StoreManagerInterface $storeManager, + private \Magento\UrlRewrite\Model\ResourceModel\UrlRewriteCollectionFactory $urlRewriteCollectionFactory ) { - $this->pageFactory = $pageFactory; - $this->collectionFactory = $collectionFactory; - $this->localeDate = $localeDate; - $this->storeManager = $storeManager; - $this->urlRewriteCollectionFactory = $urlRewriteCollectionFactory; } /** diff --git a/Installer/Command/Config.php b/Installer/Command/Config.php index 7c0ba4a..8bb88d4 100644 --- a/Installer/Command/Config.php +++ b/Installer/Command/Config.php @@ -11,26 +11,10 @@ class Config { use LoggerAware; - /** - * @var \Magento\Framework\App\Config\ScopeConfigInterface - */ - private $scopeConfig; - - /** - * @var \Magento\Framework\App\Config\Storage\WriterInterface - */ - private $configWriter; - - /** - * @param \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig - * @param \Magento\Framework\App\Config\Storage\WriterInterface $configWriter - */ public function __construct( - \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig, - \Magento\Framework\App\Config\Storage\WriterInterface $configWriter + private \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig, + private \Magento\Framework\App\Config\Storage\WriterInterface $configWriter ) { - $this->scopeConfig = $scopeConfig; - $this->configWriter = $configWriter; } /** diff --git a/Installer/Command/CopyMediaDir.php b/Installer/Command/CopyMediaDir.php index c4391f9..45628e3 100644 --- a/Installer/Command/CopyMediaDir.php +++ b/Installer/Command/CopyMediaDir.php @@ -10,18 +10,8 @@ class CopyMediaDir { use LoggerAware; - /** - * @var \Magento\Framework\Filesystem - */ - private $filesystem; - - /** - * - * @param \Magento\Framework\Filesystem $filesystem - */ - public function __construct(\Magento\Framework\Filesystem $filesystem) + public function __construct(private \Magento\Framework\Filesystem $filesystem) { - $this->filesystem = $filesystem; } /** diff --git a/Installer/Command/ProductAttribute.php b/Installer/Command/ProductAttribute.php index 7b175a5..465721d 100644 --- a/Installer/Command/ProductAttribute.php +++ b/Installer/Command/ProductAttribute.php @@ -9,42 +9,12 @@ class ProductAttribute { use LoggerAware; - /** - * @var \Magento\Catalog\Model\ResourceModel\Eav\AttributeFactory - */ - private $attributeFactory; - - /** - * @var \Magento\Catalog\Helper\Product - */ - private $productHelper; - - /** - * @var \Magento\Eav\Model\EntityFactory - */ - private $eavEntityFactory; - - /** - * @var \Magento\Eav\Model\ResourceModel\Entity\Attribute\Set\CollectionFactory - */ - private $attributeSetCollectionFactory; - - /** - * @param \Magento\Catalog\Model\ResourceModel\Eav\AttributeFactory $attributeFactory - * @param \Magento\Catalog\Helper\Product $productHelper - * @param \Magento\Eav\Model\EntityFactory $eavEntityFactory - * @param \Magento\Eav\Model\ResourceModel\Entity\Attribute\Set\CollectionFactory $attributeSetCollectionFactory - */ public function __construct( - \Magento\Catalog\Model\ResourceModel\Eav\AttributeFactory $attributeFactory, - \Magento\Catalog\Helper\Product $productHelper, - \Magento\Eav\Model\EntityFactory $eavEntityFactory, - \Magento\Eav\Model\ResourceModel\Entity\Attribute\Set\CollectionFactory $attributeSetCollectionFactory + private \Magento\Catalog\Model\ResourceModel\Eav\AttributeFactory $attributeFactory, + private \Magento\Catalog\Helper\Product $productHelper, + private \Magento\Eav\Model\EntityFactory $eavEntityFactory, + private \Magento\Eav\Model\ResourceModel\Entity\Attribute\Set\CollectionFactory $attributeSetCollectionFactory ) { - $this->attributeFactory = $attributeFactory; - $this->productHelper = $productHelper; - $this->eavEntityFactory = $eavEntityFactory; - $this->attributeSetCollectionFactory = $attributeSetCollectionFactory; } /** diff --git a/Installer/Command/ProductCollection.php b/Installer/Command/ProductCollection.php index 8fac2a3..fd7e5dd 100644 --- a/Installer/Command/ProductCollection.php +++ b/Installer/Command/ProductCollection.php @@ -9,50 +9,13 @@ class ProductCollection { use LoggerAware; - /** - * @var \Magento\Framework\Stdlib\DateTime\TimezoneInterface - */ - private $localeDate; - - /** - * @var \Magento\Store\Model\StoreManagerInterface - */ - private $storeManager; - - /** - * @var \Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory - */ - private $attributeCollectionFactory; - - /** - * @var \Magento\Catalog\Model\ResourceModel\Product\CollectionFactory - */ - private $productCollectionFactory; - - /** - * @var \Magento\Catalog\Model\Product\Visibility - */ - private $catalogProductVisibility; - - /** - * @param \Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory $attributeCollectionFactory - * @param \Magento\Catalog\Model\ResourceModel\Product\CollectionFactory $productCollectionFactory - * @param \Magento\Catalog\Model\Product\Visibility $catalogProductVisibility - * @param \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate - * @param \Magento\Store\Model\StoreManagerInterface $storeManager - */ public function __construct( - \Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory $attributeCollectionFactory, - \Magento\Catalog\Model\ResourceModel\Product\CollectionFactory $productCollectionFactory, - \Magento\Catalog\Model\Product\Visibility $catalogProductVisibility, - \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate, - \Magento\Store\Model\StoreManagerInterface $storeManager + private \Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory $attributeCollectionFactory, + private \Magento\Catalog\Model\ResourceModel\Product\CollectionFactory $productCollectionFactory, + private \Magento\Catalog\Model\Product\Visibility $catalogProductVisibility, + private \Magento\Framework\Stdlib\DateTime\TimezoneInterface $localeDate, + private \Magento\Store\Model\StoreManagerInterface $storeManager ) { - $this->attributeCollectionFactory = $attributeCollectionFactory; - $this->productCollectionFactory = $productCollectionFactory; - $this->catalogProductVisibility = $catalogProductVisibility; - $this->localeDate = $localeDate; - $this->storeManager = $storeManager; } /** diff --git a/Installer/Command/Unpack.php b/Installer/Command/Unpack.php index 1d23277..a72be5f 100644 --- a/Installer/Command/Unpack.php +++ b/Installer/Command/Unpack.php @@ -11,26 +11,10 @@ class Unpack { use LoggerAware; - /** - * @var \Magento\Framework\Archive - */ - private $archiver; - - /** - * @var \Magento\Framework\Filesystem\Io\File - */ - private $ioFile; - - /** - * @param \Magento\Framework\Archive $archiver - * @param \Magento\Framework\Filesystem\Io\File $ioFile - */ public function __construct( - \Magento\Framework\Archive $archiver, - \Magento\Framework\Filesystem\Io\File $ioFile + private \Magento\Framework\Archive $archiver, + private \Magento\Framework\Filesystem\Io\File $ioFile ) { - $this->archiver = $archiver; - $this->ioFile = $ioFile; } /** diff --git a/Installer/Command/Widget.php b/Installer/Command/Widget.php index 8e475a9..9df5577 100644 --- a/Installer/Command/Widget.php +++ b/Installer/Command/Widget.php @@ -10,34 +10,11 @@ class Widget { use LoggerAware; - /** - * @var \Magento\Store\Model\StoreManagerInterface - */ - private $storeManager; - - /** - * @var \Magento\Widget\Model\Widget\InstanceFactory - */ - private $widgetFactory; - - /** - * @var \Magento\Widget\Model\ResourceModel\Widget\Instance\CollectionFactory $collectionFactory - */ - private $collectionFactory; - - /** - * @param \Magento\Store\Model\StoreManagerInterface $storeManager - * @param \Magento\Widget\Model\Widget\InstanceFactory $widgetFactory - * @param \Magento\Widget\Model\ResourceModel\Widget\Instance\CollectionFactory $collectionFactory - */ public function __construct( - \Magento\Store\Model\StoreManagerInterface $storeManager, - \Magento\Widget\Model\Widget\InstanceFactory $widgetFactory, - \Magento\Widget\Model\ResourceModel\Widget\Instance\CollectionFactory $collectionFactory + private \Magento\Store\Model\StoreManagerInterface $storeManager, + private \Magento\Widget\Model\Widget\InstanceFactory $widgetFactory, + private \Magento\Widget\Model\ResourceModel\Widget\Instance\CollectionFactory $collectionFactory ) { - $this->storeManager = $storeManager; - $this->widgetFactory = $widgetFactory; - $this->collectionFactory = $collectionFactory; } /** diff --git a/Installer/ConfigReader.php b/Installer/ConfigReader.php index 35d816a..514a10c 100644 --- a/Installer/ConfigReader.php +++ b/Installer/ConfigReader.php @@ -24,50 +24,13 @@ class ConfigReader */ protected $currentPath; - /** - * @var ComponentRegistrar - */ - protected $componentRegistrar; - - /** - * @var ReadFactory - */ - protected $readDirFactory; - - /** - * @var Manager - */ - protected $moduleManager; - - /** - * @var ObjectManagerInterface - */ - protected $objectManager; - - /** - * @var ConfigFactory - */ - protected $configFactory; - - /** - * @param ComponentRegistrar $componentRegistrar - * @param ReadFactory $readDirFactory - * @param Manager $moduleManager - * @param ObjectManagerInterface $objectManager - * @param ConfigFactory $configFactory - */ public function __construct( - ComponentRegistrar $componentRegistrar, - ReadFactory $readDirFactory, - Manager $moduleManager, - ObjectManagerInterface $objectManager, - ConfigFactory $configFactory + protected ComponentRegistrar $componentRegistrar, + protected ReadFactory $readDirFactory, + protected Manager $moduleManager, + protected ObjectManagerInterface $objectManager, + protected ConfigFactory $configFactory ) { - $this->componentRegistrar = $componentRegistrar; - $this->readDirFactory = $readDirFactory; - $this->moduleManager = $moduleManager; - $this->objectManager = $objectManager; - $this->configFactory = $configFactory; } /** diff --git a/Installer/Helper/Collection.php b/Installer/Helper/Collection.php index 4a26c32..63e7fcb 100644 --- a/Installer/Helper/Collection.php +++ b/Installer/Helper/Collection.php @@ -4,18 +4,9 @@ class Collection { - /** - * @var \Magento\Framework\ObjectManagerInterface - */ - private $objectManager; - - /** - * @param \Magento\Framework\ObjectManagerInterface $objectManager - */ public function __construct( - \Magento\Framework\ObjectManagerInterface $objectManager + private \Magento\Framework\ObjectManagerInterface $objectManager ) { - $this->objectManager = $objectManager; } /** diff --git a/Installer/Helper/Serializer.php b/Installer/Helper/Serializer.php index 19fd046..1ef9912 100644 --- a/Installer/Helper/Serializer.php +++ b/Installer/Helper/Serializer.php @@ -4,18 +4,9 @@ class Serializer { - /** - * @var \Magento\Framework\Serialize\Serializer\Json - */ - private $jsonSerializer; - - /** - * @param CollectionFactory $collectionFactory - */ public function __construct( - \Magento\Framework\Serialize\Serializer\Json $jsonSerializer + private \Magento\Framework\Serialize\Serializer\Json $jsonSerializer ) { - $this->jsonSerializer = $jsonSerializer; } /** diff --git a/Installer/Helper/Theme.php b/Installer/Helper/Theme.php index 9e2109a..cf32130 100644 --- a/Installer/Helper/Theme.php +++ b/Installer/Helper/Theme.php @@ -11,17 +11,8 @@ class Theme */ private $memo = []; - /** - * @var CollectionFactory - */ - private $collectionFactory; - - /** - * @param CollectionFactory $collectionFactory - */ - public function __construct(CollectionFactory $collectionFactory) + public function __construct(private CollectionFactory $collectionFactory) { - $this->collectionFactory = $collectionFactory; } /** diff --git a/Installer/Installer.php b/Installer/Installer.php index aaecb27..1571ead 100644 --- a/Installer/Installer.php +++ b/Installer/Installer.php @@ -13,52 +13,15 @@ class Installer */ private $data; - /** - * @var \Magento\Framework\App\Cache\Manager - */ - private $cache; - - /** - * @var \Magento\Framework\App\State - */ - private $appState; - - /** - * @var \Magento\Framework\ObjectManagerInterface - */ - private $objectManager; - - /** - * @var ConfigReader - */ - private $configReader; - - /** - * @var RequestFactory - */ - private $requestFactory; - private $runOnlyIfRequired; - /** - * @param \Magento\Framework\App\Cache\Manager $cache - * @param \Magento\Framework\App\State $appState - * @param \Magento\Framework\ObjectManagerInterface $objectManager - * @param ConfigReader $configReader - * @param RequestFactory $requestFactory - */ public function __construct( - \Magento\Framework\App\Cache\Manager $cache, - \Magento\Framework\App\State $appState, - \Magento\Framework\ObjectManagerInterface $objectManager, - ConfigReader $configReader, - RequestFactory $requestFactory + private \Magento\Framework\App\Cache\Manager $cache, + private \Magento\Framework\App\State $appState, + private \Magento\Framework\ObjectManagerInterface $objectManager, + private ConfigReader $configReader, + private RequestFactory $requestFactory ) { - $this->cache = $cache; - $this->appState = $appState; - $this->objectManager = $objectManager; - $this->configReader = $configReader; - $this->requestFactory = $requestFactory; } public function setRunOnlyIfRequired($flag) diff --git a/Model/Installer/Composer.php b/Model/Installer/Composer.php index 3cf2c12..dc983aa 100644 --- a/Model/Installer/Composer.php +++ b/Model/Installer/Composer.php @@ -15,15 +15,10 @@ */ class Composer { - private ComposerJsonFinder $composerJsonFinder; - private Process $process; - public function __construct( - ComposerJsonFinder $composerJsonFinder, - Process $process + private ComposerJsonFinder $composerJsonFinder, + private Process $process ) { - $this->composerJsonFinder = $composerJsonFinder; - $this->process = $process; } /** diff --git a/Model/Installer/ComposerRepository.php b/Model/Installer/ComposerRepository.php index d4d91c0..3ab71ec 100644 --- a/Model/Installer/ComposerRepository.php +++ b/Model/Installer/ComposerRepository.php @@ -16,22 +16,14 @@ class ComposerRepository const URL = 'https://ci.swissuplabs.com/api/packages.json'; const HOSTNAME = 'ci.swissuplabs.com'; - private ComposerJsonFinder $composerJsonFinder; - private ScopeConfigInterface $scopeConfig; - private CurlFactory $curlFactory; - private Composer $composer; private ?array $credentials = null; public function __construct( - ComposerJsonFinder $composerJsonFinder, - ScopeConfigInterface $scopeConfig, - CurlFactory $curlFactory, - Composer $composer + private ComposerJsonFinder $composerJsonFinder, + private ScopeConfigInterface $scopeConfig, + private CurlFactory $curlFactory, + private Composer $composer ) { - $this->composerJsonFinder = $composerJsonFinder; - $this->scopeConfig = $scopeConfig; - $this->curlFactory = $curlFactory; - $this->composer = $composer; } /** From 9e58a3a6f546965a719e368b7b084db5b08188d1 Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Fri, 18 Sep 2026 16:52:13 +0300 Subject: [PATCH 16/34] Ready for psr/log --- Console/Command/Installer/PackageInstallCommand.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Console/Command/Installer/PackageInstallCommand.php b/Console/Command/Installer/PackageInstallCommand.php index 7e455ad..180b862 100644 --- a/Console/Command/Installer/PackageInstallCommand.php +++ b/Console/Command/Installer/PackageInstallCommand.php @@ -198,12 +198,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - $this->installer - ->setLogger($this->logger) - ->run($packages, array_merge($formData, [ - 'store_id' => $storeIds, - 'packages' => $packages, - ])); + $this->installer->setLogger($this->logger); + $this->installer->run($packages, array_merge($formData, [ + 'store_id' => $storeIds, + 'packages' => $packages, + ])); $output->writeln('Done.'); From 2db10545890c6e811c25a935fec4d651ecc56184 Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Fri, 18 Sep 2026 16:55:54 +0300 Subject: [PATCH 17/34] Keep installer classes in Installer folder --- Installer/Command/CategoryUpdate.php | 2 +- Installer/Command/CmsBlock.php | 2 +- Installer/Command/CmsPage.php | 2 +- Installer/Command/Config.php | 2 +- Installer/Command/CopyMediaDir.php | 2 +- Installer/Command/ProductAttribute.php | 2 +- Installer/Command/ProductCollection.php | 2 +- Installer/Command/Unpack.php | 2 +- Installer/Command/Widget.php | 2 +- Installer/Installer.php | 2 -- {Model/Traits => Installer}/LoggerAware.php | 2 +- 11 files changed, 10 insertions(+), 12 deletions(-) rename {Model/Traits => Installer}/LoggerAware.php (93%) diff --git a/Installer/Command/CategoryUpdate.php b/Installer/Command/CategoryUpdate.php index 1730ebb..ffcabcc 100644 --- a/Installer/Command/CategoryUpdate.php +++ b/Installer/Command/CategoryUpdate.php @@ -4,7 +4,7 @@ use Magento\Store\Model\Store; use Swissup\Core\Installer\Request; -use Swissup\Core\Model\Traits\LoggerAware; +use Swissup\Core\Installer\LoggerAware; class CategoryUpdate { diff --git a/Installer/Command/CmsBlock.php b/Installer/Command/CmsBlock.php index b80814e..a37fbfc 100644 --- a/Installer/Command/CmsBlock.php +++ b/Installer/Command/CmsBlock.php @@ -3,7 +3,7 @@ namespace Swissup\Core\Installer\Command; use Swissup\Core\Installer\Request; -use Swissup\Core\Model\Traits\LoggerAware; +use Swissup\Core\Installer\LoggerAware; class CmsBlock { diff --git a/Installer/Command/CmsPage.php b/Installer/Command/CmsPage.php index a8d3f7d..be00836 100644 --- a/Installer/Command/CmsPage.php +++ b/Installer/Command/CmsPage.php @@ -3,7 +3,7 @@ namespace Swissup\Core\Installer\Command; use Swissup\Core\Installer\Request; -use Swissup\Core\Model\Traits\LoggerAware; +use Swissup\Core\Installer\LoggerAware; class CmsPage { diff --git a/Installer/Command/Config.php b/Installer/Command/Config.php index 8bb88d4..3f49a22 100644 --- a/Installer/Command/Config.php +++ b/Installer/Command/Config.php @@ -5,7 +5,7 @@ use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Store\Model\ScopeInterface; use Swissup\Core\Installer\Request; -use Swissup\Core\Model\Traits\LoggerAware; +use Swissup\Core\Installer\LoggerAware; class Config { diff --git a/Installer/Command/CopyMediaDir.php b/Installer/Command/CopyMediaDir.php index 45628e3..0f30595 100644 --- a/Installer/Command/CopyMediaDir.php +++ b/Installer/Command/CopyMediaDir.php @@ -4,7 +4,7 @@ use Magento\Framework\App\Filesystem\DirectoryList; use Swissup\Core\Installer\Request; -use Swissup\Core\Model\Traits\LoggerAware; +use Swissup\Core\Installer\LoggerAware; class CopyMediaDir { diff --git a/Installer/Command/ProductAttribute.php b/Installer/Command/ProductAttribute.php index 465721d..eb185a3 100644 --- a/Installer/Command/ProductAttribute.php +++ b/Installer/Command/ProductAttribute.php @@ -3,7 +3,7 @@ namespace Swissup\Core\Installer\Command; use Swissup\Core\Installer\Request; -use Swissup\Core\Model\Traits\LoggerAware; +use Swissup\Core\Installer\LoggerAware; class ProductAttribute { diff --git a/Installer/Command/ProductCollection.php b/Installer/Command/ProductCollection.php index fd7e5dd..192b3c1 100644 --- a/Installer/Command/ProductCollection.php +++ b/Installer/Command/ProductCollection.php @@ -3,7 +3,7 @@ namespace Swissup\Core\Installer\Command; use Swissup\Core\Installer\Request; -use Swissup\Core\Model\Traits\LoggerAware; +use Swissup\Core\Installer\LoggerAware; class ProductCollection { diff --git a/Installer/Command/Unpack.php b/Installer/Command/Unpack.php index a72be5f..560fecf 100644 --- a/Installer/Command/Unpack.php +++ b/Installer/Command/Unpack.php @@ -5,7 +5,7 @@ use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Store\Model\ScopeInterface; use Swissup\Core\Installer\Request; -use Swissup\Core\Model\Traits\LoggerAware; +use Swissup\Core\Installer\LoggerAware; class Unpack { diff --git a/Installer/Command/Widget.php b/Installer/Command/Widget.php index 9df5577..b4c2d06 100644 --- a/Installer/Command/Widget.php +++ b/Installer/Command/Widget.php @@ -4,7 +4,7 @@ use Magento\Widget\Model\Widget\Instance; use Swissup\Core\Installer\Request; -use Swissup\Core\Model\Traits\LoggerAware; +use Swissup\Core\Installer\LoggerAware; class Widget { diff --git a/Installer/Installer.php b/Installer/Installer.php index 1571ead..11c29b7 100644 --- a/Installer/Installer.php +++ b/Installer/Installer.php @@ -2,8 +2,6 @@ namespace Swissup\Core\Installer; -use Swissup\Core\Model\Traits\LoggerAware; - class Installer { use LoggerAware; diff --git a/Model/Traits/LoggerAware.php b/Installer/LoggerAware.php similarity index 93% rename from Model/Traits/LoggerAware.php rename to Installer/LoggerAware.php index 58e9d40..4b9c4a8 100644 --- a/Model/Traits/LoggerAware.php +++ b/Installer/LoggerAware.php @@ -1,6 +1,6 @@ Date: Fri, 18 Sep 2026 17:01:29 +0300 Subject: [PATCH 18/34] WIP --- .../Command/Installer/PackageInstallCommand.php | 14 +++----------- Installer/Command/CategoryUpdate.php | 4 ---- Installer/Command/CmsBlock.php | 4 ---- Installer/Command/CmsPage.php | 4 ---- Installer/Command/Config.php | 4 ---- Installer/Command/CopyMediaDir.php | 4 ---- Installer/Command/Product.php | 5 ----- Installer/Command/ProductAttribute.php | 4 ---- Installer/Command/ProductCollection.php | 4 ---- Installer/Command/Unpack.php | 4 ---- Installer/Command/Widget.php | 4 ---- 11 files changed, 3 insertions(+), 52 deletions(-) diff --git a/Console/Command/Installer/PackageInstallCommand.php b/Console/Command/Installer/PackageInstallCommand.php index 180b862..0160e77 100644 --- a/Console/Command/Installer/PackageInstallCommand.php +++ b/Console/Command/Installer/PackageInstallCommand.php @@ -18,19 +18,11 @@ class PackageInstallCommand extends Command const INPUT_KEY_STORE = 'store'; const INPUT_KEY_NO_DOWNLOAD = 'no-download'; - /** - * @var InputInterface - */ - protected $input; + protected InputInterface $input; - /** - * @var OutputInterface - */ - protected $output; + protected OutputInterface $output; - /** - * @var \Psr\Log\LoggerInterface - */ + /** @var \Psr\Log\LoggerInterface */ protected $logger; public function __construct( diff --git a/Installer/Command/CategoryUpdate.php b/Installer/Command/CategoryUpdate.php index ffcabcc..2e28074 100644 --- a/Installer/Command/CategoryUpdate.php +++ b/Installer/Command/CategoryUpdate.php @@ -15,10 +15,6 @@ public function __construct( ) { } - /** - * @param Request $request - * @return void - */ public function execute(Request $request) { $this->logger->info('Category Update: Prepare category data'); diff --git a/Installer/Command/CmsBlock.php b/Installer/Command/CmsBlock.php index a37fbfc..60d10cd 100644 --- a/Installer/Command/CmsBlock.php +++ b/Installer/Command/CmsBlock.php @@ -17,10 +17,6 @@ public function __construct( ) { } - /** - * @param Request $request - * @return void - */ public function execute(Request $request) { $this->logger->info('Cms Blocks: Backup existing and create new blocks'); diff --git a/Installer/Command/CmsPage.php b/Installer/Command/CmsPage.php index be00836..c95c2f7 100644 --- a/Installer/Command/CmsPage.php +++ b/Installer/Command/CmsPage.php @@ -18,10 +18,6 @@ public function __construct( ) { } - /** - * @param Request $request - * @return void - */ public function execute(Request $request) { $idsToInstall = array_flip($request->getExtraOptions()); diff --git a/Installer/Command/Config.php b/Installer/Command/Config.php index 3f49a22..c4a1d44 100644 --- a/Installer/Command/Config.php +++ b/Installer/Command/Config.php @@ -17,10 +17,6 @@ public function __construct( ) { } - /** - * @param Request $request - * @return void - */ public function execute(Request $request) { $this->logger->info('Config: Update store parameters'); diff --git a/Installer/Command/CopyMediaDir.php b/Installer/Command/CopyMediaDir.php index 0f30595..9b0eda5 100644 --- a/Installer/Command/CopyMediaDir.php +++ b/Installer/Command/CopyMediaDir.php @@ -14,10 +14,6 @@ public function __construct(private \Magento\Framework\Filesystem $filesystem) { } - /** - * @param Request $request - * @return void - */ public function execute(Request $request) { $this->logger->info('Resources: Copy media files'); diff --git a/Installer/Command/Product.php b/Installer/Command/Product.php index a831d65..f538d87 100644 --- a/Installer/Command/Product.php +++ b/Installer/Command/Product.php @@ -6,14 +6,9 @@ class Product extends ProductCollection { - /** - * @param Request $request - * @return void - */ public function execute(Request $request) { // $this->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 index eb185a3..2c23fee 100644 --- a/Installer/Command/ProductAttribute.php +++ b/Installer/Command/ProductAttribute.php @@ -17,10 +17,6 @@ public function __construct( ) { } - /** - * @param Request $request - * @return void - */ public function execute(Request $request) { $this->logger->info('Product Attributes: Update attributes'); diff --git a/Installer/Command/ProductCollection.php b/Installer/Command/ProductCollection.php index 192b3c1..a6d8a35 100644 --- a/Installer/Command/ProductCollection.php +++ b/Installer/Command/ProductCollection.php @@ -18,10 +18,6 @@ public function __construct( ) { } - /** - * @param Request $request - * @return void - */ public function execute(Request $request) { $this->logger->info('Product Collection: Prepare collections'); diff --git a/Installer/Command/Unpack.php b/Installer/Command/Unpack.php index 560fecf..a38ecf7 100644 --- a/Installer/Command/Unpack.php +++ b/Installer/Command/Unpack.php @@ -17,10 +17,6 @@ public function __construct( ) { } - /** - * @param Request $request - * @return void - */ public function execute(Request $request) { $this->logger->info('Unpack'); diff --git a/Installer/Command/Widget.php b/Installer/Command/Widget.php index b4c2d06..c27ef2e 100644 --- a/Installer/Command/Widget.php +++ b/Installer/Command/Widget.php @@ -17,10 +17,6 @@ public function __construct( ) { } - /** - * @param Request $request - * @return void - */ public function execute(Request $request) { $this->logger->info('Widgets: Backup existing and create new widgets'); From 975f93bf1e80818cd73555808ded6e14b226b352 Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Fri, 18 Sep 2026 17:37:37 +0300 Subject: [PATCH 19/34] Update deps --- composer.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index f85c547..9fcc4c3 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,11 @@ "version": "1.13.1", "license": "OSL-3.0", "require": { - "php": "^8.0" + "php": "^8.0", + "composer/composer": ">=2.0", + "psr/log": ">=1.0", + "symfony/console": ">=4.4", + "symfony/process": ">=4.4" }, "autoload": { "files": [ "registration.php" ], From f375c19cf388c949ec3e9dc34de66c95e1094d65 Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Fri, 18 Sep 2026 17:38:46 +0300 Subject: [PATCH 20/34] Use psr/log dependency --- .../Installer/PackageInstallCommand.php | 6 ++-- Installer/Command/CategoryUpdate.php | 4 +-- Installer/Command/CmsBlock.php | 4 +-- Installer/Command/CmsPage.php | 4 +-- Installer/Command/Config.php | 4 +-- Installer/Command/CopyMediaDir.php | 4 +-- Installer/Command/ProductAttribute.php | 4 +-- Installer/Command/ProductCollection.php | 4 +-- Installer/Command/Unpack.php | 4 +-- Installer/Command/Widget.php | 4 +-- Installer/Installer.php | 15 +++++++- Installer/LoggerAware.php | 36 ------------------- 12 files changed, 34 insertions(+), 59 deletions(-) delete mode 100644 Installer/LoggerAware.php diff --git a/Console/Command/Installer/PackageInstallCommand.php b/Console/Command/Installer/PackageInstallCommand.php index 0160e77..287e0eb 100644 --- a/Console/Command/Installer/PackageInstallCommand.php +++ b/Console/Command/Installer/PackageInstallCommand.php @@ -3,6 +3,7 @@ namespace Swissup\Core\Console\Command\Installer; use Magento\Framework\Component\ComponentRegistrar; +use Psr\Log\LoggerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\ArrayInput; @@ -19,11 +20,8 @@ class PackageInstallCommand extends Command const INPUT_KEY_NO_DOWNLOAD = 'no-download'; protected InputInterface $input; - protected OutputInterface $output; - - /** @var \Psr\Log\LoggerInterface */ - protected $logger; + protected LoggerInterface $logger; public function __construct( protected \Magento\Store\Model\StoreManagerInterface $storeManager, diff --git a/Installer/Command/CategoryUpdate.php b/Installer/Command/CategoryUpdate.php index 2e28074..0c043a3 100644 --- a/Installer/Command/CategoryUpdate.php +++ b/Installer/Command/CategoryUpdate.php @@ -4,11 +4,11 @@ use Magento\Store\Model\Store; use Swissup\Core\Installer\Request; -use Swissup\Core\Installer\LoggerAware; +use Psr\Log\LoggerAwareTrait; class CategoryUpdate { - use LoggerAware; + use LoggerAwareTrait; public function __construct( private \Swissup\Core\Installer\Helper\Collection $collectionHelper diff --git a/Installer/Command/CmsBlock.php b/Installer/Command/CmsBlock.php index 60d10cd..e0d6374 100644 --- a/Installer/Command/CmsBlock.php +++ b/Installer/Command/CmsBlock.php @@ -3,11 +3,11 @@ namespace Swissup\Core\Installer\Command; use Swissup\Core\Installer\Request; -use Swissup\Core\Installer\LoggerAware; +use Psr\Log\LoggerAwareTrait; class CmsBlock { - use LoggerAware; + use LoggerAwareTrait; public function __construct( private \Magento\Cms\Model\BlockFactory $blockFactory, diff --git a/Installer/Command/CmsPage.php b/Installer/Command/CmsPage.php index c95c2f7..9a4aeda 100644 --- a/Installer/Command/CmsPage.php +++ b/Installer/Command/CmsPage.php @@ -3,11 +3,11 @@ namespace Swissup\Core\Installer\Command; use Swissup\Core\Installer\Request; -use Swissup\Core\Installer\LoggerAware; +use Psr\Log\LoggerAwareTrait; class CmsPage { - use LoggerAware; + use LoggerAwareTrait; public function __construct( private \Magento\Cms\Model\PageFactory $pageFactory, diff --git a/Installer/Command/Config.php b/Installer/Command/Config.php index c4a1d44..0c77557 100644 --- a/Installer/Command/Config.php +++ b/Installer/Command/Config.php @@ -5,11 +5,11 @@ use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Store\Model\ScopeInterface; use Swissup\Core\Installer\Request; -use Swissup\Core\Installer\LoggerAware; +use Psr\Log\LoggerAwareTrait; class Config { - use LoggerAware; + use LoggerAwareTrait; public function __construct( private \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig, diff --git a/Installer/Command/CopyMediaDir.php b/Installer/Command/CopyMediaDir.php index 9b0eda5..c425b19 100644 --- a/Installer/Command/CopyMediaDir.php +++ b/Installer/Command/CopyMediaDir.php @@ -4,11 +4,11 @@ use Magento\Framework\App\Filesystem\DirectoryList; use Swissup\Core\Installer\Request; -use Swissup\Core\Installer\LoggerAware; +use Psr\Log\LoggerAwareTrait; class CopyMediaDir { - use LoggerAware; + use LoggerAwareTrait; public function __construct(private \Magento\Framework\Filesystem $filesystem) { diff --git a/Installer/Command/ProductAttribute.php b/Installer/Command/ProductAttribute.php index 2c23fee..9d8d132 100644 --- a/Installer/Command/ProductAttribute.php +++ b/Installer/Command/ProductAttribute.php @@ -3,11 +3,11 @@ namespace Swissup\Core\Installer\Command; use Swissup\Core\Installer\Request; -use Swissup\Core\Installer\LoggerAware; +use Psr\Log\LoggerAwareTrait; class ProductAttribute { - use LoggerAware; + use LoggerAwareTrait; public function __construct( private \Magento\Catalog\Model\ResourceModel\Eav\AttributeFactory $attributeFactory, diff --git a/Installer/Command/ProductCollection.php b/Installer/Command/ProductCollection.php index a6d8a35..de383ec 100644 --- a/Installer/Command/ProductCollection.php +++ b/Installer/Command/ProductCollection.php @@ -3,11 +3,11 @@ namespace Swissup\Core\Installer\Command; use Swissup\Core\Installer\Request; -use Swissup\Core\Installer\LoggerAware; +use Psr\Log\LoggerAwareTrait; class ProductCollection { - use LoggerAware; + use LoggerAwareTrait; public function __construct( private \Magento\Catalog\Model\ResourceModel\Product\Attribute\CollectionFactory $attributeCollectionFactory, diff --git a/Installer/Command/Unpack.php b/Installer/Command/Unpack.php index a38ecf7..f15e42e 100644 --- a/Installer/Command/Unpack.php +++ b/Installer/Command/Unpack.php @@ -5,11 +5,11 @@ use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Store\Model\ScopeInterface; use Swissup\Core\Installer\Request; -use Swissup\Core\Installer\LoggerAware; +use Psr\Log\LoggerAwareTrait; class Unpack { - use LoggerAware; + use LoggerAwareTrait; public function __construct( private \Magento\Framework\Archive $archiver, diff --git a/Installer/Command/Widget.php b/Installer/Command/Widget.php index c27ef2e..42186cc 100644 --- a/Installer/Command/Widget.php +++ b/Installer/Command/Widget.php @@ -4,11 +4,11 @@ use Magento\Widget\Model\Widget\Instance; use Swissup\Core\Installer\Request; -use Swissup\Core\Installer\LoggerAware; +use Psr\Log\LoggerAwareTrait; class Widget { - use LoggerAware; + use LoggerAwareTrait; public function __construct( private \Magento\Store\Model\StoreManagerInterface $storeManager, diff --git a/Installer/Installer.php b/Installer/Installer.php index 11c29b7..36967de 100644 --- a/Installer/Installer.php +++ b/Installer/Installer.php @@ -2,9 +2,13 @@ namespace Swissup\Core\Installer; +use Psr\Log\LoggerAwareTrait; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; + class Installer { - use LoggerAware; + use LoggerAwareTrait; /** * @var array @@ -144,6 +148,15 @@ public function getCommandAliases($packages) return $result; } + /** + * @param string $package + * @return array + */ + private function getLogger(): LoggerInterface + { + return $this->logger ??= new NullLogger(); + } + /** * @param string $package * @return array diff --git a/Installer/LoggerAware.php b/Installer/LoggerAware.php deleted file mode 100644 index 4b9c4a8..0000000 --- a/Installer/LoggerAware.php +++ /dev/null @@ -1,36 +0,0 @@ -logger = $logger; - - return $this; - } - - /** - * @return LoggerInterface - */ - public function getLogger() - { - if (!$this->logger) { - $this->logger = new NullLogger(); - } - - return $this->logger; - } -} From cc0b306cbb7d168453df6a777b340a538bd08e86 Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Fri, 18 Sep 2026 17:46:04 +0300 Subject: [PATCH 21/34] Fixed failing ci.swissuplabs tests --- composer.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 9fcc4c3..b4b1681 100644 --- a/composer.json +++ b/composer.json @@ -6,10 +6,10 @@ "license": "OSL-3.0", "require": { "php": "^8.0", - "composer/composer": ">=2.0", - "psr/log": ">=1.0", - "symfony/console": ">=4.4", - "symfony/process": ">=4.4" + "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" ], From 9015f974526f920f699944ea2e6483b0a6963a76 Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Mon, 21 Sep 2026 11:18:00 +0300 Subject: [PATCH 22/34] Cleanup terminal composer output keeping installation info --- Console/Command/Installer/PackageInstallCommand.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Console/Command/Installer/PackageInstallCommand.php b/Console/Command/Installer/PackageInstallCommand.php index 287e0eb..bee6de7 100644 --- a/Console/Command/Installer/PackageInstallCommand.php +++ b/Console/Command/Installer/PackageInstallCommand.php @@ -4,6 +4,7 @@ use Magento\Framework\Component\ComponentRegistrar; use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\ArrayInput; @@ -45,9 +46,10 @@ protected function initialize(InputInterface $input, OutputInterface $output): v { $this->input = $input; $this->output = $output; - $this->logger = new ConsoleLogger($output); - - $output->setVerbosity(OutputInterface::VERBOSITY_DEBUG); + $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')) { From d6dc01e98fee52698e8c9ed3d8ac31594552585e Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Mon, 21 Sep 2026 11:39:09 +0300 Subject: [PATCH 23/34] WIP --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d7a9a02..3b49083 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 utilize some common tasks used by other modules. ## Installation @@ -10,7 +10,7 @@ composer require swissup/module-core bin/magento setup:upgrade ``` -## Swissup installer +## Swissup Installer Aavailable commands From 04c1c09db48ff5978ef757d63e08ae21cc578dfc Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Mon, 21 Sep 2026 12:17:08 +0300 Subject: [PATCH 24/34] Remove swissup_core_module table, model and collection The table stored nothing but the identity key used by the deprecated subscription-checker module, and keeping db_schema.xml forced a setup:upgrade run. Dropped the model, resource model, collection and the Module\{Installer,LicenseValidator,MessageLogger} classes that only served it, along with setup_version. swissup:module now reads dependencies from PackageInfo directly and no longer prints the identity key. Core replaces swissup/module-subscription-checker in composer. Co-Authored-By: Claude Opus 5 --- Api/Data/ModuleInterface.php | 115 -------- Console/Command/ModuleCommand.php | 23 +- Model/Module.php | 307 ---------------------- Model/Module/Installer.php | 93 ------- Model/Module/LicenseValidator.php | 221 ---------------- Model/Module/MessageLogger.php | 58 ---- Model/ResourceModel/Module.php | 23 -- Model/ResourceModel/Module/Collection.php | 25 -- etc/db_schema.xml | 28 -- etc/db_schema_whitelist.json | 26 -- etc/module.xml | 2 +- 11 files changed, 11 insertions(+), 910 deletions(-) delete mode 100644 Api/Data/ModuleInterface.php delete mode 100644 Model/Module.php delete mode 100644 Model/Module/Installer.php delete mode 100644 Model/Module/LicenseValidator.php delete mode 100644 Model/Module/MessageLogger.php delete mode 100644 Model/ResourceModel/Module.php delete mode 100644 Model/ResourceModel/Module/Collection.php delete mode 100644 etc/db_schema.xml delete mode 100644 etc/db_schema_whitelist.json 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 @@ -loader = $loader; - $this->moduleFactory = $moduleFactory; + $this->packageInfo = $packageInfo; parent::__construct(); } @@ -145,15 +145,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/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/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/module.xml b/etc/module.xml index 1d8b430..7209af9 100644 --- a/etc/module.xml +++ b/etc/module.xml @@ -1,5 +1,5 @@ - + From c57ea469f479c2d4cfa89a72737d62eb4f35862d Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Mon, 21 Sep 2026 13:15:26 +0300 Subject: [PATCH 25/34] Fix outdated urls in comments --- Model/ComponentList/Loader/Remote.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Model/ComponentList/Loader/Remote.php b/Model/ComponentList/Loader/Remote.php index e25428f..0b46194 100644 --- a/Model/ComponentList/Loader/Remote.php +++ b/Model/ComponentList/Loader/Remote.php @@ -323,8 +323,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 +362,7 @@ protected function getPackagesUrlPrefix() \Magento\Store\Model\ScopeInterface::SCOPE_STORE ); - // docs.swissuplabs.com/packages + // swissup.github.io/packages-latest return ($useHttps ? 'https://' : 'http://') . $url; } } From 5feae95efb6ccc98c9ecffdaf0ae1b97feef5965 Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Mon, 21 Sep 2026 14:01:49 +0300 Subject: [PATCH 26/34] swissup:auth:check command --- .../Command/Installer/AuthCheckCommand.php | 99 ++++++++++++++++ Model/Installer/ComposerRepository.php | 111 ++++++++++++++---- README.md | 1 + etc/di.xml | 1 + 4 files changed, 188 insertions(+), 24 deletions(-) create mode 100644 Console/Command/Installer/AuthCheckCommand.php diff --git a/Console/Command/Installer/AuthCheckCommand.php b/Console/Command/Installer/AuthCheckCommand.php new file mode 100644 index 0000000..f2c6f2e --- /dev/null +++ b/Console/Command/Installer/AuthCheckCommand.php @@ -0,0 +1,99 @@ +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; + } + + $latest = $this->remote->getComponentsInfo(); + $packages = $this->repository->getPackagesBatch($this->repository->getUsername(), $keys); + + $table = new Table($output); + $table->setHeaders(['Provider', 'Key', 'Packages']); + + foreach ($keys as $key) { + $summary = $packages[$key] instanceof \Exception + ? '' . $packages[$key]->getMessage() . '' + : $this->summarize($packages[$key], $latest); + + $table->addRow([$this->repository->getKeyDomain($key) ?: '', $key, $summary]); + } + + $table->render(); + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ''); + return Cli::RETURN_FAILURE; + } + + return Cli::RETURN_SUCCESS; + } + + /** + * @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/Model/Installer/ComposerRepository.php b/Model/Installer/ComposerRepository.php index 3ab71ec..3d2f33a 100644 --- a/Model/Installer/ComposerRepository.php +++ b/Model/Installer/ComposerRepository.php @@ -7,7 +7,9 @@ use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\Composer\ComposerJsonFinder; use Magento\Framework\Exception\AuthenticationException; -use Magento\Framework\HTTP\Client\CurlFactory; +use Magento\Framework\HTTP\AsyncClient\Request; +use Magento\Framework\HTTP\AsyncClient\Response; +use Magento\Framework\HTTP\AsyncClientInterface; class ComposerRepository { @@ -21,7 +23,7 @@ class ComposerRepository public function __construct( private ComposerJsonFinder $composerJsonFinder, private ScopeConfigInterface $scopeConfig, - private CurlFactory $curlFactory, + private AsyncClientInterface $asyncClient, private Composer $composer ) { } @@ -271,38 +273,99 @@ public function saveCredentials($username, $password) */ public function getPackages($username, $password) { - $response = $this->fetch(self::URL, $username, $password); - - // includes are relative to the repository base url - foreach (array_keys($response['includes'] ?? []) as $include) { - $url = dirname(self::URL) . '/' . ltrim($include, '/'); - $response['packages'] = array_merge( - $response['packages'] ?? [], - $this->fetch($url, $username, $password)['packages'] ?? [] - ); + $packages = $this->getPackagesBatch($username, [$password])[$password]; + + if ($packages instanceof \Exception) { + throw $packages; } - return $response['packages'] ?? []; + return $packages; } /** - * @param string $url * @param string $username - * @param string $password + * @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 fetch($url, $username, $password) + private function parse($username, $url, Response $response) { - $client = $this->curlFactory->create(); - $client->setOption(CURLOPT_FOLLOWLOCATION, true); - $client->setOption(CURLOPT_MAXREDIRS, 5); - $client->setTimeout(30); - $client->setCredentials($username, $password); - $client->get($url); - - $status = $client->getStatus(); + $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.', @@ -314,7 +377,7 @@ private function fetch($url, $username, $password) throw new \RuntimeException(sprintf('%s returned %s response code', $url, $status)); } - $data = json_decode($client->getBody(), true); + $data = json_decode($response->getBody(), true); if (!is_array($data)) { throw new \RuntimeException(sprintf('%s returned malformed response', $url)); } diff --git a/README.md b/README.md index 3b49083..1773bde 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Command | Description `bin/magento swissup:auth:add {key}` | Add SwissupLabs access key `bin/magento swissup:auth:remove {key}` | Remove SwissupLabs access key `bin/magento swissup:auth:show` | Display SwissupLabs access keys currently in use +`bin/magento swissup:auth:check` | Display SwissupLabs access keys currently in use with count of available packages per key **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 diff --git a/etc/di.xml b/etc/di.xml index 9762d16..bc1ab1d 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -14,6 +14,7 @@ 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 From f212452c595eb4093cb2301c659b9b9941116da6 Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Mon, 21 Sep 2026 14:50:03 +0300 Subject: [PATCH 27/34] Key components by package name instead of module code A metapackage and the module it requires share one module code, since convertPackageNameToModuleName strips the "module-" prefix. Both collapsed into a single item, and the metapackage - which sorts first in the feed - won every field. The remote loader worked around that by dropping metapackages altogether. Key the items by package name, which is unique, and let the consumers that list components hide the metapackages instead. This fixes latest_version for the packages whose metapackage lags behind the module: Swissup_BreezeAi reported 1.2.0 instead of 1.8.8, so no update was ever offered for it. The synthetic swissup/subscription entry is dropped as well - it collided with swissup/module-subscription the same way, and the only field it contributed that anything still reads is a homepage link. Co-Authored-By: Claude Opus 5 --- Console/Command/ModuleCommand.php | 32 +++++++++++-------- Console/Command/ModuleListCommand.php | 2 +- Model/ComponentList/Loader.php | 7 ++++ Model/ComponentList/Loader/AbstractLoader.php | 11 +++---- Model/ComponentList/Loader/Remote.php | 22 ------------- Ui/DataProvider/ModuleListingDataProvider.php | 2 +- 6 files changed, 32 insertions(+), 44 deletions(-) diff --git a/Console/Command/ModuleCommand.php b/Console/Command/ModuleCommand.php index dba5533..0debc75 100644 --- a/Console/Command/ModuleCommand.php +++ b/Console/Command/ModuleCommand.php @@ -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'); 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/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 0b46194..623288a 100644 --- a/Model/ComponentList/Loader/Remote.php +++ b/Model/ComponentList/Loader/Remote.php @@ -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; } diff --git a/Ui/DataProvider/ModuleListingDataProvider.php b/Ui/DataProvider/ModuleListingDataProvider.php index 27ce746..2b1dcd0 100644 --- a/Ui/DataProvider/ModuleListingDataProvider.php +++ b/Ui/DataProvider/ModuleListingDataProvider.php @@ -59,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) { From 390313511ff499f2816613ab60cfaeb293939fba Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Mon, 21 Sep 2026 15:39:16 +0300 Subject: [PATCH 28/34] Rework auth:show and auth:check output auth:show prints plain Username/Password lines, auth:check adds Username column and moves provider into its own column. Co-Authored-By: Claude Opus 5 --- Console/Command/Installer/AuthCheckCommand.php | 14 ++++++++++---- Console/Command/Installer/AuthShowCommand.php | 11 ++--------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/Console/Command/Installer/AuthCheckCommand.php b/Console/Command/Installer/AuthCheckCommand.php index f2c6f2e..f0b0a34 100644 --- a/Console/Command/Installer/AuthCheckCommand.php +++ b/Console/Command/Installer/AuthCheckCommand.php @@ -35,18 +35,24 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Cli::RETURN_SUCCESS; } + $username = $this->repository->getUsername(); $latest = $this->remote->getComponentsInfo(); - $packages = $this->repository->getPackagesBatch($this->repository->getUsername(), $keys); + $packages = $this->repository->getPackagesBatch($username, $keys); $table = new Table($output); - $table->setHeaders(['Provider', 'Key', 'Packages']); + $table->setHeaders(['Username', 'Key', 'Provider', 'Packages']); - foreach ($keys as $key) { + foreach ($keys as $i => $key) { $summary = $packages[$key] instanceof \Exception ? '' . $packages[$key]->getMessage() . '' : $this->summarize($packages[$key], $latest); - $table->addRow([$this->repository->getKeyDomain($key) ?: '', $key, $summary]); + $table->addRow([ + $i ? '' : $username, + $key, + $this->repository->getKeyDomain($key) ?: '', + $summary, + ]); } $table->render(); diff --git a/Console/Command/Installer/AuthShowCommand.php b/Console/Command/Installer/AuthShowCommand.php index 7e30f48..9f53edd 100644 --- a/Console/Command/Installer/AuthShowCommand.php +++ b/Console/Command/Installer/AuthShowCommand.php @@ -4,7 +4,6 @@ use Magento\Framework\Console\Cli; use Swissup\Core\Model\Installer\ComposerRepository; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -32,14 +31,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Cli::RETURN_SUCCESS; } - $table = new Table($output); - $table->setHeaders(['Provider', 'Key']); - - foreach ($keys as $key) { - $table->addRow([$this->repository->getKeyDomain($key) ?: '', $key]); - } - - $table->render(); + $output->writeln('Username: ' . $this->repository->getUsername()); + $output->writeln('Password: ' . implode(' ', $keys)); } catch (\Exception $e) { $output->writeln('' . $e->getMessage() . ''); return Cli::RETURN_FAILURE; From ad254bec120c36a1006450ca92c3d0f3f8a12e17 Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Mon, 21 Sep 2026 16:00:24 +0300 Subject: [PATCH 29/34] Offer to remove duplicate access keys in auth:check Co-Authored-By: Claude Opus 5 --- .../Command/Installer/AuthCheckCommand.php | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/Console/Command/Installer/AuthCheckCommand.php b/Console/Command/Installer/AuthCheckCommand.php index f0b0a34..6f69f68 100644 --- a/Console/Command/Installer/AuthCheckCommand.php +++ b/Console/Command/Installer/AuthCheckCommand.php @@ -8,6 +8,7 @@ use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; class AuthCheckCommand extends Command { @@ -56,6 +57,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $table->render(); + + $this->cleanup($input, $output, $username, $keys); } catch (\Exception $e) { $output->writeln('' . $e->getMessage() . ''); return Cli::RETURN_FAILURE; @@ -64,6 +67,38 @@ protected function execute(InputInterface $input, OutputInterface $output): int 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 From 4bd0deab07b14395fbf0c3ca16da36069719fe5f Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Mon, 21 Sep 2026 16:03:24 +0300 Subject: [PATCH 30/34] Update Readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1773bde..71e35a2 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,9 @@ Command | Description `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:remove {key}` | Remove SwissupLabs access key -`bin/magento swissup:auth:show` | Display SwissupLabs access keys currently in use `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 From 8d0f803b9e7771587e2f53ce04aac3c0f35f0d3e Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Mon, 21 Sep 2026 16:08:29 +0300 Subject: [PATCH 31/34] Explain why the installer re-run passes --no-download Co-Authored-By: Claude Opus 5 --- Console/Command/Installer/PackageInstallCommand.php | 1 + 1 file changed, 1 insertion(+) diff --git a/Console/Command/Installer/PackageInstallCommand.php b/Console/Command/Installer/PackageInstallCommand.php index bee6de7..48d236c 100644 --- a/Console/Command/Installer/PackageInstallCommand.php +++ b/Console/Command/Installer/PackageInstallCommand.php @@ -279,6 +279,7 @@ 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] ); From f44d35f8a537657e34463fe5b7e06d207c747966 Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Mon, 21 Sep 2026 17:45:21 +0300 Subject: [PATCH 32/34] Better actions dropdown --- view/adminhtml/ui_component/swissup_module_manager.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 607252bef249fa0f23f5d5772a2b53cde21a84c2 Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Tue, 22 Sep 2026 10:50:56 +0300 Subject: [PATCH 33/34] Address review comments --- Installer/Command/ProductCollection.php | 2 ++ Installer/Command/Unpack.php | 6 +++--- Installer/Command/Widget.php | 2 ++ Installer/ConfigReader.php | 2 +- Installer/Helper/Renderer.php | 4 +--- README.md | 4 ++-- 6 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Installer/Command/ProductCollection.php b/Installer/Command/ProductCollection.php index de383ec..c46a8bb 100644 --- a/Installer/Command/ProductCollection.php +++ b/Installer/Command/ProductCollection.php @@ -49,6 +49,8 @@ public function execute(Request $request) ] ); break; + default: + continue 2; } if ($collection->getSize()) { diff --git a/Installer/Command/Unpack.php b/Installer/Command/Unpack.php index f15e42e..8973012 100644 --- a/Installer/Command/Unpack.php +++ b/Installer/Command/Unpack.php @@ -21,9 +21,9 @@ public function execute(Request $request) { $this->logger->info('Unpack'); $params = $request->getParams(); - $destanation = $params['destination']; - $this->ioFile->checkAndCreateFolder($destanation); + $destination = $params['destination']; + $this->ioFile->checkAndCreateFolder($destination); $archive = $params['archive']; - $this->archiver->unpack($archive, $destanation); + $this->archiver->unpack($archive, $destination); } } diff --git a/Installer/Command/Widget.php b/Installer/Command/Widget.php index 42186cc..1d99d53 100644 --- a/Installer/Command/Widget.php +++ b/Installer/Command/Widget.php @@ -84,6 +84,8 @@ public function execute(Request $request) private function getPageGroupData($data, $defaultData) { + $groupName = $groupData = null; + if (isset($data['handle'])) { $groupName = $this->getGroupName($data['handle']); $groupData = [ diff --git a/Installer/ConfigReader.php b/Installer/ConfigReader.php index 514a10c..e834f15 100644 --- a/Installer/ConfigReader.php +++ b/Installer/ConfigReader.php @@ -350,7 +350,7 @@ private function preparePath($value) $result = $subdir . $value; $result = realpath($result); - if (strpos($result, $subdir) !== 0) { + if ($result === false || strpos($result, $subdir) !== 0) { throw new SecurityViolationException( __( 'Error during "%1" processing. Relative paths are forbidden: "%2"', diff --git a/Installer/Helper/Renderer.php b/Installer/Helper/Renderer.php index 38059b0..f389837 100644 --- a/Installer/Helper/Renderer.php +++ b/Installer/Helper/Renderer.php @@ -16,9 +16,7 @@ public function render(array $request, $path) if (!is_readable($path)) { throw new FileSystemException(__( 'File %1 can\'t be read. Please check if it exists and has read permissions.', - [ - $path - ] + $path )); } diff --git a/README.md b/README.md index 71e35a2..64d9741 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Swissup Core This module ships Swissup Installer and adds Swissup menu and config entries -to Magento backend. It also utilize some common tasks used by other modules. +to Magento backend. It also provides a set of common tasks used by other modules. ## Installation @@ -12,7 +12,7 @@ bin/magento setup:upgrade ## Swissup Installer -Aavailable commands +Available commands Command | Description ------------------------------------------------|--------------------------------------- From 16207b9c59fcd0c888bd75b0e90107727f475e22 Mon Sep 17 00:00:00 2001 From: Vova Yatsyuk Date: Tue, 22 Sep 2026 14:41:15 +0300 Subject: [PATCH 34/34] Fixed date attribute creation inside collection command --- Installer/Command/ProductCollection.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Installer/Command/ProductCollection.php b/Installer/Command/ProductCollection.php index c46a8bb..079b145 100644 --- a/Installer/Command/ProductCollection.php +++ b/Installer/Command/ProductCollection.php @@ -24,6 +24,7 @@ public function execute(Request $request) $data = $request->getParams(); $visibility = $this->catalogProductVisibility->getVisibleInCatalogIds(); + $isAllStores = in_array(0, $request->getStoreIds()); $attributes = $this->attributeCollectionFactory->create() ->addFieldToFilter('attribute_code', ['in' => array_keys($data)]); @@ -35,10 +36,13 @@ public function execute(Request $request) 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, [ @@ -80,7 +84,7 @@ public function execute(Request $request) foreach ($visibleProducts as $product) { $product->addAttributeUpdate( $attribute->getAttributeCode(), - (int) in_array(0, $request->getStoreIds()), // value + $isAllStores ? $value : $unsetValue, \Magento\Store\Model\Store::DEFAULT_STORE_ID );