From e4d137c9bbc3b9a71508eed8d595d187eea37e5b Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Fri, 20 Sep 2024 15:02:19 +0100 Subject: [PATCH 01/90] SqlCliCommand - Tone-down warnings We're going to start using mysql wrapper script in bknix. The warning applies to some badly written wrappers, but the warning doesn't apply here. --- src/Command/SqlCliCommand.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Command/SqlCliCommand.php b/src/Command/SqlCliCommand.php index 2b353e05..f43b259b 100644 --- a/src/Command/SqlCliCommand.php +++ b/src/Command/SqlCliCommand.php @@ -54,7 +54,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $datasource->loadFromCiviDSN($this->pickDsn($input->getOption('target'))); $mysql = Process::findCommand('mysql'); - if (Process::isShellScript($mysql)) { + if (Process::isShellScript($mysql) && !static::supportsDefaultsFile($mysql)) { $output->getErrorOutput()->writeln("[SqlCommand] WARNING: The mysql command appears to be a wrapper script. In some environments, this may interfere with credential passing."); } @@ -158,4 +158,9 @@ protected function pickDsn($target) { return $dsn; } + protected function supportsDefaultsFile(string $bin): bool { + $code = file_get_contents($bin); + return preg_match(';@ respect --defaults-file;', $code); + } + } From bbe40f3643d97486671b2328bbd791ea03505058 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 23 Sep 2024 11:34:56 -0700 Subject: [PATCH 02/90] ConsoleQueueRunner - Relax overly-specific type-constraint --- src/Util/ConsoleQueueRunner.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Util/ConsoleQueueRunner.php b/src/Util/ConsoleQueueRunner.php index be369eea..831ead7a 100644 --- a/src/Util/ConsoleQueueRunner.php +++ b/src/Util/ConsoleQueueRunner.php @@ -34,12 +34,12 @@ class ConsoleQueueRunner { /** * ConsoleQueueRunner constructor. * - * @param \Symfony\Component\Console\Style\SymfonyStyle $io + * @param \Symfony\Component\Console\Style\StyleInterface $io * @param \CRM_Queue_Queue $queue * @param bool $dryRun * @param bool $step */ - public function __construct(\Symfony\Component\Console\Style\SymfonyStyle $io, \CRM_Queue_Queue $queue, $dryRun = FALSE, $step = FALSE) { + public function __construct(\Symfony\Component\Console\Style\StyleInterface $io, \CRM_Queue_Queue $queue, $dryRun = FALSE, $step = FALSE) { $this->io = $io; $this->queue = $queue; $this->dryRun = $dryRun; From c9b24499a7963d8f353751d8b28bca834db46f6a Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 23 Sep 2024 11:35:51 -0700 Subject: [PATCH 03/90] BaseCommand - Drop $this->getIO() in favor of Cv::io() The latter is much more flexible / agreeable to refactoring. The former is only used in a couple places, and it makes the class-hierarchy more precious. --- src/Command/BaseCommand.php | 14 -------------- src/Command/UpgradeDbCommand.php | 6 +++--- src/Util/UrlCommandTrait.php | 2 +- 3 files changed, 4 insertions(+), 18 deletions(-) diff --git a/src/Command/BaseCommand.php b/src/Command/BaseCommand.php index a742fb3f..2647d191 100644 --- a/src/Command/BaseCommand.php +++ b/src/Command/BaseCommand.php @@ -6,34 +6,20 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\SymfonyStyle; class BaseCommand extends Command { use OptionCallbackTrait; - /** - * @var \Symfony\Component\Console\Style\StyleInterface - */ - private $io; - /** * @param \Symfony\Component\Console\Input\InputInterface $input * @param \Symfony\Component\Console\Output\OutputInterface $output */ protected function initialize(InputInterface $input, OutputInterface $output) { parent::initialize($input, $output); - $this->io = new SymfonyStyle($input, $output); $this->runOptionCallbacks($input, $output); } - /** - * @return \Symfony\Component\Console\Style\StyleInterface - */ - protected function getIO() { - return $this->io; - } - protected function assertBooted() { if (!$this->isBooted()) { throw new \Exception("Error: This command requires bootstrapping, but the system does not appear to be bootstrapped. Perhaps you set --level=none?"); diff --git a/src/Command/UpgradeDbCommand.php b/src/Command/UpgradeDbCommand.php index ae487d32..c1f98f89 100644 --- a/src/Command/UpgradeDbCommand.php +++ b/src/Command/UpgradeDbCommand.php @@ -181,7 +181,7 @@ protected function runCoreUpgrade(bool $isFirstTry, string $dbVer, string $postU $upgrade->setPreUpgradeMessage($preUpgradeMessage, $dbVer, $codeVer); if ($preUpgradeMessage) { $output->writeln(\CRM_Utils_String::htmlToText($preUpgradeMessage), $this->niceVerbosity); - if (!$this->getIO()->confirm('Continue?')) { + if (!\Civi\Cv\Cv::io()->confirm('Continue?')) { $output->writeln("Abort"); return 1; } @@ -219,7 +219,7 @@ protected function runCoreUpgrade(bool $isFirstTry, string $dbVer, string $postU } $output->writeln("Executing upgrade...", $this->niceVerbosity); - $runner = new ConsoleQueueRunner($this->getIO(), $queue, $input->getOption('dry-run'), $input->getOption('step')); + $runner = new ConsoleQueueRunner(\Civi\Cv\Cv::io(), $queue, $input->getOption('dry-run'), $input->getOption('step')); $runner->runAll(); $output->writeln("Finishing upgrade...", $this->niceVerbosity); @@ -297,7 +297,7 @@ protected function runExtensionUpgrade(bool $isFirstTry): int { } } - $runner = new ConsoleQueueRunner($this->getIO(), $queue, $input->getOption('dry-run'), $input->getOption('step')); + $runner = new ConsoleQueueRunner(\Civi\Cv\Cv::io(), $queue, $input->getOption('dry-run'), $input->getOption('step')); $runner->runAll(); return 0; } diff --git a/src/Util/UrlCommandTrait.php b/src/Util/UrlCommandTrait.php index 9f22ba4a..3004d866 100644 --- a/src/Util/UrlCommandTrait.php +++ b/src/Util/UrlCommandTrait.php @@ -77,7 +77,7 @@ protected function createUrls(InputInterface $input, OutputInterface $output, bo if ($input->getOption('login')) { if (!\CRM_Extension_System::singleton()->getMapper()->isActiveModule('authx')) { - if ($this->getIO()->confirm('Enable authx?')) { + if (\Civi\Cv\Cv::io()->confirm('Enable authx?')) { // ^^ Does the question go to STDERR or STDOUT? $output->getErrorOutput()->writeln('Enabling extension "authx"'); civicrm_api3('Extension', 'enable', ['key' => 'authx']); From 0d25b40502d1bc167942dc19ee1491b362a58d8a Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 23 Sep 2024 11:44:02 -0700 Subject: [PATCH 04/90] (REF) Move `isBooted()`, `assertBooted()` from `BaseCommand` to `BootTrait` --- lib/src/Util/BootTrait.php | 13 +++++++++++++ src/Command/BaseCommand.php | 13 ------------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/src/Util/BootTrait.php b/lib/src/Util/BootTrait.php index c3baf46f..47ed62f4 100644 --- a/lib/src/Util/BootTrait.php +++ b/lib/src/Util/BootTrait.php @@ -290,4 +290,17 @@ private function bootLogger(OutputInterface $output): InternalLogger { return new SymfonyConsoleLogger('BootTrait', $output); } + /** + * @return bool + */ + protected function isBooted() { + return defined('CIVICRM_DSN'); + } + + protected function assertBooted() { + if (!$this->isBooted()) { + throw new \Exception("Error: This command requires bootstrapping, but the system does not appear to be bootstrapped. Perhaps you set --level=none?"); + } + } + } diff --git a/src/Command/BaseCommand.php b/src/Command/BaseCommand.php index 2647d191..168218e5 100644 --- a/src/Command/BaseCommand.php +++ b/src/Command/BaseCommand.php @@ -20,12 +20,6 @@ protected function initialize(InputInterface $input, OutputInterface $output) { $this->runOptionCallbacks($input, $output); } - protected function assertBooted() { - if (!$this->isBooted()) { - throw new \Exception("Error: This command requires bootstrapping, but the system does not appear to be bootstrapped. Perhaps you set --level=none?"); - } - } - /** * Execute an API call. If it fails, display a formatted error. * @@ -101,11 +95,4 @@ public function parseOptionalOption(InputInterface $input, $rawNames, $omittedDe return $omittedDefault; } - /** - * @return bool - */ - protected function isBooted() { - return defined('CIVICRM_DSN'); - } - } From e04f655e62ad153f4a085cdaec18089a13cb19b6 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 23 Sep 2024 12:06:20 -0700 Subject: [PATCH 05/90] (REF) Move BaseCommand::parseOptionalOption() to OptionalOption::parse() --- lib/src/Util/OptionalOption.php | 42 +++++++++++++++++ src/Command/BaseCommand.php | 35 -------------- src/Command/CoreInstallCommand.php | 3 +- src/Command/CoreUninstallCommand.php | 3 +- src/Command/ExtensionEnableCommand.php | 2 +- src/Util/StructuredOutputTrait.php | 4 +- tests/Command/BaseCommandTest.php | 50 -------------------- tests/Util/OptionalOptionTest.php | 65 ++++++++++++++++++++++++++ 8 files changed, 114 insertions(+), 90 deletions(-) create mode 100644 lib/src/Util/OptionalOption.php delete mode 100644 tests/Command/BaseCommandTest.php create mode 100644 tests/Util/OptionalOptionTest.php diff --git a/lib/src/Util/OptionalOption.php b/lib/src/Util/OptionalOption.php new file mode 100644 index 00000000..38bf8c92 --- /dev/null +++ b/lib/src/Util/OptionalOption.php @@ -0,0 +1,42 @@ + Means "--refresh=auto"; see $omittedDefault + * cv en -r ==> Means "--refresh=yes"; see $activeDefault + * cv en -r=yes ==> Means "--refresh=yes" + * cv en -r=no ==> Means "--refresh=no" + * + * @param \CvDeps\Symfony\Component\Console\Input\InputInterface|\Symfony\Component\Console\Input\InputInterface $input + * @param array $rawNames + * Ex: array('-r', '--refresh'). + * @param string $omittedDefault + * Value to use if option is completely omitted. + * @param string $activeDefault + * Value to use if option is activated without data. + * @return string + */ + public static function parse($input, $rawNames, $omittedDefault, $activeDefault) { + $value = NULL; + foreach ($rawNames as $rawName) { + if ($input->hasParameterOption($rawName)) { + if (NULL === $input->getParameterOption($rawName)) { + return $activeDefault; + } + else { + return $input->getParameterOption($rawName); + } + } + } + return $omittedDefault; + } + +} diff --git a/src/Command/BaseCommand.php b/src/Command/BaseCommand.php index 168218e5..c0b698fc 100644 --- a/src/Command/BaseCommand.php +++ b/src/Command/BaseCommand.php @@ -60,39 +60,4 @@ protected function callApiSuccess(InputInterface $input, OutputInterface $output return $result; } - /** - * Parse an option's data. This is for options where the default behavior - * (of total omission) differs from the activated behavior - * (of an active but unspecified option). - * - * Example, suppose we want these interpretations: - * cv en ==> Means "--refresh=auto"; see $omittedDefault - * cv en -r ==> Means "--refresh=yes"; see $activeDefault - * cv en -r=yes ==> Means "--refresh=yes" - * cv en -r=no ==> Means "--refresh=no" - * - * @param \Symfony\Component\Console\Input\InputInterface $input - * @param array $rawNames - * Ex: array('-r', '--refresh'). - * @param string $omittedDefault - * Value to use if option is completely omitted. - * @param string $activeDefault - * Value to use if option is activated without data. - * @return string - */ - public function parseOptionalOption(InputInterface $input, $rawNames, $omittedDefault, $activeDefault) { - $value = NULL; - foreach ($rawNames as $rawName) { - if ($input->hasParameterOption($rawName)) { - if (NULL === $input->getParameterOption($rawName)) { - return $activeDefault; - } - else { - return $input->getParameterOption($rawName); - } - } - } - return $omittedDefault; - } - } diff --git a/src/Command/CoreInstallCommand.php b/src/Command/CoreInstallCommand.php index 76166030..f4c38cd0 100644 --- a/src/Command/CoreInstallCommand.php +++ b/src/Command/CoreInstallCommand.php @@ -2,6 +2,7 @@ namespace Civi\Cv\Command; use Civi\Cv\Encoder; +use Civi\Cv\Util\OptionalOption; use Civi\Cv\Util\SetupCommandTrait; use Civi\Cv\Util\DebugDispatcherTrait; use Symfony\Component\Console\Input\InputInterface; @@ -57,7 +58,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $debugMode = FALSE; - $debugEvent = $this->parseOptionalOption($input, ['--debug-event'], NULL, ''); + $debugEvent = OptionalOption::parse($input, ['--debug-event'], NULL, ''); if ($debugEvent !== NULL) { $eventNames = $this->findEventNames($setup->getDispatcher(), $debugEvent); $this->printEventListeners($output, $setup->getDispatcher(), $eventNames); diff --git a/src/Command/CoreUninstallCommand.php b/src/Command/CoreUninstallCommand.php index d6563fca..343db33f 100644 --- a/src/Command/CoreUninstallCommand.php +++ b/src/Command/CoreUninstallCommand.php @@ -1,6 +1,7 @@ bootSetupSubsystem($input, $output); - $debugEvent = $this->parseOptionalOption($input, ['--debug-event'], NULL, ''); + $debugEvent = OptionalOption::parse($input, ['--debug-event'], NULL, ''); if ($debugEvent !== NULL) { $eventNames = $this->findEventNames($setup->getDispatcher(), $debugEvent); $this->printEventListeners($output, $setup->getDispatcher(), $eventNames); diff --git a/src/Command/ExtensionEnableCommand.php b/src/Command/ExtensionEnableCommand.php index 0de3bba5..0d57f90f 100644 --- a/src/Command/ExtensionEnableCommand.php +++ b/src/Command/ExtensionEnableCommand.php @@ -45,7 +45,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Refresh extensions if (a) ---refresh enabled or (b) there's a cache-miss. $refresh = $input->getOption('refresh') ? 'yes' : 'auto'; - // $refresh = $this->parseOptionalOption($input, array('--refresh', '-r'), 'auto', 'yes'); + // $refresh = OptionalOption::parse(array('--refresh', '-r'), 'auto', 'yes'); while (TRUE) { if ($refresh === 'yes') { $output->writeln("Refreshing extension cache"); diff --git a/src/Util/StructuredOutputTrait.php b/src/Util/StructuredOutputTrait.php index f9fd553e..66064542 100644 --- a/src/Util/StructuredOutputTrait.php +++ b/src/Util/StructuredOutputTrait.php @@ -113,7 +113,7 @@ protected function configureOutputOptions($config = []) { * @see Encoder::getFormats */ protected function sendResult(InputInterface $input, OutputInterface $output, $result) { - $flat = $this->parseOptionalOption($input, ['--flat'], FALSE, '.'); + $flat = OptionalOption::parse($input, ['--flat'], FALSE, '.'); if ($flat !== FALSE) { $result = ArrayUtil::implodeTree($flat, $result); } @@ -157,7 +157,7 @@ protected function sendTable(InputInterface $input, OutputInterface $output, $re return; } - $flat = $this->parseOptionalOption($input, ['--flat'], FALSE, '.'); + $flat = OptionalOption::parse($input, ['--flat'], FALSE, '.'); if ($flat !== FALSE) { $filtered = ArrayUtil::filterColumns($records, $columns); $flattened = ArrayUtil::implodeTree($flat, $filtered); diff --git a/tests/Command/BaseCommandTest.php b/tests/Command/BaseCommandTest.php deleted file mode 100644 index 808b5a0e..00000000 --- a/tests/Command/BaseCommandTest.php +++ /dev/null @@ -1,50 +0,0 @@ -addOption('refresh', array('r'), InputOption::VALUE_OPTIONAL, 'auto'); - - $input = new ArgvInput($inputArgv, $c->getDefinition()); - $this->assertEquals($expectValue, $c->parseOptionalOption($input, array('-r', '--refresh'), 'auto', 'yes')); - } - -} diff --git a/tests/Util/OptionalOptionTest.php b/tests/Util/OptionalOptionTest.php new file mode 100644 index 00000000..2f8f9c0b --- /dev/null +++ b/tests/Util/OptionalOptionTest.php @@ -0,0 +1,65 @@ +push(...$this->createInputOutput($inputArgv)); + try { + $this->assertEquals($expectValue, OptionalOption::parse(Cv::input(), ['-r', '--refresh'], 'auto', 'yes')); + } + finally { + Cv::ioStack()->pop(); + } + } + + /** + * @return array + * [0 => InputInterface, 1 => OutputInterface] + */ + protected function createInputOutput(array $argv = NULL): array { + $input = new ArgvInput($argv); + $input->setInteractive(FALSE); + $output = new NullOutput(); + return [$input, $output]; + } + +} From 35aa90d02b6469dc9bc3794c17e75aeb89703e1c Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 23 Sep 2024 12:27:46 -0700 Subject: [PATCH 06/90] (REF) Move BaseCommand::callApiSuccess() to VerboseApi::callApi3Success() --- src/Command/BaseCommand.php | 41 -------------------- src/Command/ExtensionDisableCommand.php | 5 ++- src/Command/ExtensionDownloadCommand.php | 11 +++--- src/Command/ExtensionEnableCommand.php | 7 ++-- src/Command/ExtensionListCommand.php | 7 ++-- src/Command/ExtensionUninstallCommand.php | 7 ++-- src/Command/ExtensionUpgradeDbCommand.php | 3 +- src/Command/FlushCommand.php | 3 +- src/Util/VerboseApi.php | 47 +++++++++++++++++++++++ 9 files changed, 72 insertions(+), 59 deletions(-) create mode 100644 src/Util/VerboseApi.php diff --git a/src/Command/BaseCommand.php b/src/Command/BaseCommand.php index c0b698fc..99ca6681 100644 --- a/src/Command/BaseCommand.php +++ b/src/Command/BaseCommand.php @@ -1,7 +1,6 @@ runOptionCallbacks($input, $output); } - /** - * Execute an API call. If it fails, display a formatted error. - * - * Note: If there is an error, we still return it softly so that the - * command can exit gracefully. - * - * @param \Symfony\Component\Console\Input\InputInterface $input - * @param \Symfony\Component\Console\Output\OutputInterface $output - * @param $entity - * @param $action - * @param $params - * @return mixed - */ - protected function callApiSuccess(InputInterface $input, OutputInterface $output, $entity, $action, $params) { - $this->assertBooted(); - $params['debug'] = 1; - if (!isset($params['version'])) { - $params['version'] = 3; - } - $output->writeln("Calling $entity $action API", OutputInterface::VERBOSITY_DEBUG); - $result = \civicrm_api($entity, $action, $params); - if (!empty($result['is_error']) || $output->isDebug()) { - $data = array( - 'entity' => $entity, - 'action' => $action, - 'params' => $params, - 'result' => $result, - ); - if (!empty($result['is_error'])) { - $output->getErrorOutput()->writeln("Error: API Call Failed: " - . Encoder::encode($data, 'pretty')); - } - else { - $output->writeln("API success" . Encoder::encode($data, 'pretty'), - OutputInterface::VERBOSITY_DEBUG); - } - } - return $result; - } - } diff --git a/src/Command/ExtensionDisableCommand.php b/src/Command/ExtensionDisableCommand.php index abc058a3..c5991d03 100644 --- a/src/Command/ExtensionDisableCommand.php +++ b/src/Command/ExtensionDisableCommand.php @@ -1,6 +1,7 @@ boot($input, $output); - list ($foundKeys, $missingKeys) = $this->parseKeys($input, $output); + [$foundKeys, $missingKeys] = $this->parseKeys($input, $output); // Uninstall what's recognized or what looks like an ext key. $disableKeys = array_merge($foundKeys, preg_grep('/\./', $missingKeys)); @@ -52,7 +53,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln("Disabling extension \"$key\""); } - $result = $this->callApiSuccess($input, $output, 'Extension', 'disable', array( + $result = VerboseApi::callApi3Success('Extension', 'disable', array( 'keys' => $disableKeys, )); return empty($result['is_error']) ? 0 : 1; diff --git a/src/Command/ExtensionDownloadCommand.php b/src/Command/ExtensionDownloadCommand.php index f5820af6..cb62a723 100644 --- a/src/Command/ExtensionDownloadCommand.php +++ b/src/Command/ExtensionDownloadCommand.php @@ -3,6 +3,7 @@ use Civi\Cv\Util\Filesystem; use Civi\Cv\Util\HeadlessDownloader; +use Civi\Cv\Util\VerboseApi; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -91,7 +92,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int while (TRUE) { if ($refresh === 'yes' && $this->isBooted()) { $output->writeln("Refreshing extension cache"); - $result = $this->callApiSuccess($input, $output, 'Extension', 'refresh', array( + $result = VerboseApi::callApi3Success('Extension', 'refresh', array( 'local' => FALSE, 'remote' => TRUE, )); @@ -100,7 +101,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - list ($downloads, $errors) = $this->parseDownloads($input); + [$downloads, $errors] = $this->parseDownloads($input); if ($refresh == 'auto' && !empty($errors)) { $output->writeln("Extension cache does not contain requested item(s)"); $refresh = 'yes'; @@ -135,7 +136,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int else { $output->writeln("Downloading extension \"$key\" ($url)"); $this->assertBooted(); - $result = $this->callApiSuccess($input, $output, 'Extension', 'download', array( + $result = VerboseApi::callApi3Success('Extension', 'download', array( 'key' => $key, 'url' => $url, 'install' => !$input->getOption('no-install'), @@ -145,7 +146,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int case 'install': $output->writeln("Found extension \"$key\". Enabling."); - $result = $this->callApiSuccess($input, $output, 'Extension', 'enable', array( + $result = VerboseApi::callApi3Success('Extension', 'enable', array( 'key' => $key, )); break; @@ -225,7 +226,7 @@ protected function parseDownloads(InputInterface $input) { $origExpr = $keyOrName; $url = NULL; if (strpos($keyOrName, '@') !== FALSE) { - list ($keyOrName, $url) = explode('@', $keyOrName, 2); + [$keyOrName, $url] = explode('@', $keyOrName, 2); } if (empty($keyOrName) && !empty($url)) { diff --git a/src/Command/ExtensionEnableCommand.php b/src/Command/ExtensionEnableCommand.php index 0d57f90f..201cba29 100644 --- a/src/Command/ExtensionEnableCommand.php +++ b/src/Command/ExtensionEnableCommand.php @@ -1,6 +1,7 @@ writeln("Refreshing extension cache"); - $result = $this->callApiSuccess($input, $output, 'Extension', 'refresh', array( + $result = VerboseApi::callApi3Success('Extension', 'refresh', array( 'local' => TRUE, 'remote' => FALSE, )); @@ -58,7 +59,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - list ($foundKeys, $missingKeys) = $this->parseKeys($input, $output); + [$foundKeys, $missingKeys] = $this->parseKeys($input, $output); if ($refresh == 'auto' && !empty($missingKeys)) { $output->writeln("Extension cache does not contain requested item(s)"); $refresh = 'yes'; @@ -87,7 +88,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln("Enabling extension \"$key\""); } - $result = $this->callApiSuccess($input, $output, 'Extension', 'install', array( + $result = VerboseApi::callApi3Success('Extension', 'install', array( 'keys' => $foundKeys, )); return empty($result['is_error']) ? 0 : 1; diff --git a/src/Command/ExtensionListCommand.php b/src/Command/ExtensionListCommand.php index b5577451..a26475d6 100644 --- a/src/Command/ExtensionListCommand.php +++ b/src/Command/ExtensionListCommand.php @@ -5,6 +5,7 @@ use Civi\Cv\Util\ArrayUtil; use Civi\Cv\Util\Relativizer; use Civi\Cv\Util\StructuredOutputTrait; +use Civi\Cv\Util\VerboseApi; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -82,7 +83,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ? (OutputInterface::OUTPUT_NORMAL | OutputInterface::VERBOSITY_NORMAL) : (OutputInterface::OUTPUT_NORMAL | OutputInterface::VERBOSITY_VERBOSE); - list($local, $remote) = $this->parseLocalRemote($input); + [$local, $remote] = $this->parseLocalRemote($input); if ($extRepoUrl = $this->parseRepoUrl($input)) { global $civicrm_setting; @@ -97,7 +98,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($input->getOption('refresh')) { $output->writeln("Refreshing extensions", $wo); - $result = $this->callApiSuccess($input, $output, 'Extension', 'refresh', array( + $result = VerboseApi::callApi3Success('Extension', 'refresh', array( 'local' => $local, 'remote' => $remote, )); @@ -136,7 +137,7 @@ protected function getRemoteInfos() { */ protected function find($input) { $regex = $input->getArgument('regex'); - list($local, $remote) = $this->parseLocalRemote($input); + [$local, $remote] = $this->parseLocalRemote($input); if ($input->getOption('installed')) { $statusFilter = array('installed'); diff --git a/src/Command/ExtensionUninstallCommand.php b/src/Command/ExtensionUninstallCommand.php index 839ab593..3a740713 100644 --- a/src/Command/ExtensionUninstallCommand.php +++ b/src/Command/ExtensionUninstallCommand.php @@ -1,6 +1,7 @@ boot($input, $output); - list ($foundKeys, $missingKeys) = $this->parseKeys($input, $output); + [$foundKeys, $missingKeys] = $this->parseKeys($input, $output); // Uninstall what's recognized or what looks like an ext key. $uninstallKeys = array_merge($foundKeys, preg_grep('/\./', $missingKeys)); @@ -52,14 +53,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln("Uninstalling extension \"$key\""); } - $result = $this->callApiSuccess($input, $output, 'Extension', 'disable', array( + $result = VerboseApi::callApi3Success('Extension', 'disable', array( 'keys' => $uninstallKeys, )); if (!empty($result['is_error'])) { return 1; } - $result = $this->callApiSuccess($input, $output, 'Extension', 'uninstall', array( + $result = VerboseApi::callApi3Success('Extension', 'uninstall', array( 'keys' => $uninstallKeys, )); return empty($result['is_error']) ? 0 : 1; diff --git a/src/Command/ExtensionUpgradeDbCommand.php b/src/Command/ExtensionUpgradeDbCommand.php index 41f87295..da593d84 100644 --- a/src/Command/ExtensionUpgradeDbCommand.php +++ b/src/Command/ExtensionUpgradeDbCommand.php @@ -1,6 +1,7 @@ boot($input, $output); $output->writeln("Applying database upgrades from extensions"); - $result = $this->callApiSuccess($input, $output, 'Extension', 'upgrade', array()); + $result = VerboseApi::callApi3Success('Extension', 'upgrade', array()); if (!empty($result['is_error'])) { return 1; } diff --git a/src/Command/FlushCommand.php b/src/Command/FlushCommand.php index b522a90a..e6db6843 100644 --- a/src/Command/FlushCommand.php +++ b/src/Command/FlushCommand.php @@ -2,6 +2,7 @@ namespace Civi\Cv\Command; use Civi\Cv\Util\BootTrait; +use Civi\Cv\Util\VerboseApi; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -34,7 +35,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $output->writeln("Flushing system caches"); - $result = $this->callApiSuccess($input, $output, 'System', 'flush', $params); + $result = VerboseApi::callApi3Success('System', 'flush', $params); return empty($result['is_error']) ? 0 : 1; } diff --git a/src/Util/VerboseApi.php b/src/Util/VerboseApi.php new file mode 100644 index 00000000..475e774b --- /dev/null +++ b/src/Util/VerboseApi.php @@ -0,0 +1,47 @@ +writeln("Calling $entity $action API", OutputInterface::VERBOSITY_DEBUG); + $result = \civicrm_api($entity, $action, $params); + if (!empty($result['is_error']) || $output->isDebug()) { + $data = array( + 'entity' => $entity, + 'action' => $action, + 'params' => $params, + 'result' => $result, + ); + if (!empty($result['is_error'])) { + $output->getErrorOutput()->writeln("Error: API Call Failed: " + . Encoder::encode($data, 'pretty')); + } + else { + $output->writeln("API success" . Encoder::encode($data, 'pretty'), + OutputInterface::VERBOSITY_DEBUG); + } + } + return $result; + } + +} From a32eb359c005498766681ba244f9887beb2f48e2 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 23 Sep 2024 12:33:20 -0700 Subject: [PATCH 07/90] (NFC) OptionCallbackTrait --- src/Util/OptionCallbackTrait.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Util/OptionCallbackTrait.php b/src/Util/OptionCallbackTrait.php index 0b7742e4..7295faf9 100644 --- a/src/Util/OptionCallbackTrait.php +++ b/src/Util/OptionCallbackTrait.php @@ -33,6 +33,7 @@ abstract public function getDefinition(); * @param string $name * The name of the option to * @param callable $callback + * Function(InputInterface $input, OutputInterface, $output, string $optionName) * @return $this */ public function addOptionCallback($name, $callback) { From 22e9a820cb51876fcb2ab91c629bdd7f9eb48b9f Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 23 Sep 2024 13:17:31 -0700 Subject: [PATCH 08/90] Track active $application. Convert BaseApplication::__construct() to BaseApplication::configure() This is a semantic change to how BaseApplication is initialized. However, I don't believe anyone else is using this yet, so it's academic. --- lib/src/BaseApplication.php | 5 +++-- lib/src/Cv.php | 7 +++++++ lib/src/Util/IOStack.php | 9 ++++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/src/BaseApplication.php b/lib/src/BaseApplication.php index 9c4c668f..2f51677a 100644 --- a/lib/src/BaseApplication.php +++ b/lib/src/BaseApplication.php @@ -24,6 +24,8 @@ public static function main(string $name, ?string $binDir, array $argv) { try { $application = new static($name); + Cv::ioStack()->replace('app', $application); + $application->configure(); $argv = AliasFilter::filter($argv); $result = $application->run(new CvArgvInput($argv), Cv::ioStack()->current('output')); } @@ -38,8 +40,7 @@ public static function main(string $name, ?string $binDir, array $argv) { exit($result); } - public function __construct($name = 'UNKNOWN', $version = 'UNKNOWN') { - parent::__construct($name, $version); + public function configure() { $this->setCatchExceptions(TRUE); $this->setAutoExit(FALSE); diff --git a/lib/src/Cv.php b/lib/src/Cv.php index bb5e4915..26e30db9 100644 --- a/lib/src/Cv.php +++ b/lib/src/Cv.php @@ -79,6 +79,13 @@ public static function ioStack(): IOStack { return static::$instances[__FUNCTION__]; } + /** + * @return \CvDeps\Symfony\Component\Console\Application|\Symfony\Component\Console\Application + */ + public static function app() { + return static::ioStack()->current('app'); + } + /** * @return \CvDeps\Symfony\Component\Console\Input\InputInterface|\Symfony\Component\Console\Input\InputInterface */ diff --git a/lib/src/Util/IOStack.php b/lib/src/Util/IOStack.php index 08f85e56..a8e734b1 100644 --- a/lib/src/Util/IOStack.php +++ b/lib/src/Util/IOStack.php @@ -21,16 +21,19 @@ class IOStack { * * @param \Symfony\Component\Console\Input\InputInterface $input * @param \Symfony\Component\Console\Output\OutputInterface $output + * @param \Symfony\Component\Console\Application|null $app * @return scalar * Internal identifier for the stack-frame. ID formatting is not guaranteed. */ - public function push(\Symfony\Component\Console\Input\InputInterface $input, \Symfony\Component\Console\Output\OutputInterface $output) { + public function push(\Symfony\Component\Console\Input\InputInterface $input, \Symfony\Component\Console\Output\OutputInterface $output, ?\Symfony\Component\Console\Application $app = NULL) { ++static::$id; + $app = $app ?: ($this->stack[0]['app'] ?? NULL); array_unshift($this->stack, [ 'id' => static::$id, 'input' => $input, 'output' => $output, 'io' => new SymfonyStyle($input, $output), + 'app' => $app, ]); return static::$id; } @@ -68,6 +71,10 @@ public function get($id, string $property) { return NULL; } + public function replace($property, $value) { + $this->stack[0][$property] = $value; + } + public function reset() { $this->stack = []; } From 8c52475ff025578afb7c654f2fe8171a61fac015 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 23 Sep 2024 21:58:20 -0700 Subject: [PATCH 09/90] Don't explicitly call BootTrait, configureBootOptions --- src/Command/AngularHtmlListCommand.php | 3 --- src/Command/AngularHtmlShowCommand.php | 4 ---- src/Command/AngularModuleListCommand.php | 3 --- src/Command/Api4Command.php | 3 --- src/Command/ApiBatchCommand.php | 4 ---- src/Command/ApiCommand.php | 3 --- src/Command/BaseExtensionCommand.php | 3 --- src/Command/BootCommand.php | 4 ---- src/Command/CliCommand.php | 4 ---- src/Command/CoreCheckReqCommand.php | 5 ++++- src/Command/CoreInstallCommand.php | 5 ++++- src/Command/CoreUninstallCommand.php | 5 ++++- src/Command/DebugContainerCommand.php | 3 --- src/Command/DebugDispatcherCommand.php | 3 --- src/Command/EditCommand.php | 4 ---- src/Command/EvalCommand.php | 3 --- src/Command/ExtensionDisableCommand.php | 1 - src/Command/ExtensionDownloadCommand.php | 1 - src/Command/ExtensionEnableCommand.php | 1 - src/Command/ExtensionListCommand.php | 1 - src/Command/ExtensionUninstallCommand.php | 1 - src/Command/ExtensionUpgradeDbCommand.php | 1 - src/Command/FillCommand.php | 4 ---- src/Command/FlushCommand.php | 4 ---- src/Command/HttpCommand.php | 1 - src/Command/PathCommand.php | 1 - src/Command/PipeCommand.php | 5 ----- src/Command/ScriptCommand.php | 4 ---- src/Command/SettingGetCommand.php | 3 --- src/Command/SettingRevertCommand.php | 3 --- src/Command/SettingSetCommand.php | 3 --- src/Command/ShowCommand.php | 3 --- src/Command/SqlCliCommand.php | 4 ---- src/Command/UpgradeDbCommand.php | 3 --- src/Command/UpgradeGetCommand.php | 3 --- src/Command/UrlCommand.php | 1 - src/Util/SetupCommandTrait.php | 1 - 37 files changed, 12 insertions(+), 96 deletions(-) diff --git a/src/Command/AngularHtmlListCommand.php b/src/Command/AngularHtmlListCommand.php index b191577e..d0713348 100644 --- a/src/Command/AngularHtmlListCommand.php +++ b/src/Command/AngularHtmlListCommand.php @@ -1,7 +1,6 @@ configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/AngularHtmlShowCommand.php b/src/Command/AngularHtmlShowCommand.php index 41801d2c..9114b5ae 100644 --- a/src/Command/AngularHtmlShowCommand.php +++ b/src/Command/AngularHtmlShowCommand.php @@ -2,7 +2,6 @@ namespace Civi\Cv\Command; use Civi\Cv\Util\Process; -use Civi\Cv\Util\BootTrait; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -10,8 +9,6 @@ class AngularHtmlShowCommand extends BaseCommand { - use BootTrait; - /** * @param string|null $name */ @@ -38,7 +35,6 @@ protected function configure() { cv ang:html:show crmMailing/BlockMailing.html --diff | colordiff cv ang:html:show "~/crmMailing/BlockMailing.html" '); - $this->configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/AngularModuleListCommand.php b/src/Command/AngularModuleListCommand.php index 5bc51f57..925018d4 100644 --- a/src/Command/AngularModuleListCommand.php +++ b/src/Command/AngularModuleListCommand.php @@ -1,7 +1,6 @@ configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/Api4Command.php b/src/Command/Api4Command.php index 1c796304..4a3668ba 100644 --- a/src/Command/Api4Command.php +++ b/src/Command/Api4Command.php @@ -3,7 +3,6 @@ use Civi\Cv\Encoder; use Civi\Cv\Util\Api4ArgParser; -use Civi\Cv\Util\BootTrait; use Civi\Cv\Util\StructuredOutputTrait; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -12,7 +11,6 @@ class Api4Command extends BaseCommand { - use BootTrait; use StructuredOutputTrait; /** @@ -122,7 +120,6 @@ protected function configure() { NOTE: To change the default output format, set CV_OUTPUT. "); - $this->configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/ApiBatchCommand.php b/src/Command/ApiBatchCommand.php index 59d9ad9d..c8a4c3e7 100644 --- a/src/Command/ApiBatchCommand.php +++ b/src/Command/ApiBatchCommand.php @@ -1,15 +1,12 @@ configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/ApiCommand.php b/src/Command/ApiCommand.php index 14e15e72..4ceb348b 100644 --- a/src/Command/ApiCommand.php +++ b/src/Command/ApiCommand.php @@ -2,7 +2,6 @@ namespace Civi\Cv\Command; use Civi\Cv\Encoder; -use Civi\Cv\Util\BootTrait; use Civi\Cv\Util\StructuredOutputTrait; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -11,7 +10,6 @@ class ApiCommand extends BaseCommand { - use BootTrait; use StructuredOutputTrait; /** @@ -47,7 +45,6 @@ protected function configure() { TIP: To display a full backtrace of any errors, pass "-vv" (very verbose). '); - $this->configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/BaseExtensionCommand.php b/src/Command/BaseExtensionCommand.php index efab304d..78a567b5 100644 --- a/src/Command/BaseExtensionCommand.php +++ b/src/Command/BaseExtensionCommand.php @@ -1,15 +1,12 @@ setName('php:boot') ->setDescription('Generate PHP bootstrap code'); - $this->configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/CliCommand.php b/src/Command/CliCommand.php index 9dca5f27..789c2d5c 100644 --- a/src/Command/CliCommand.php +++ b/src/Command/CliCommand.php @@ -6,19 +6,15 @@ // ********************** use Civi\Cv\Application; -use Civi\Cv\Util\BootTrait; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class CliCommand extends BaseCommand { - use BootTrait; - protected function configure() { $this ->setName('cli') ->setDescription('Load interactive command line'); - $this->configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/CoreCheckReqCommand.php b/src/Command/CoreCheckReqCommand.php index d9836efc..8a1cedc4 100644 --- a/src/Command/CoreCheckReqCommand.php +++ b/src/Command/CoreCheckReqCommand.php @@ -35,7 +35,10 @@ protected function configure() { Example: Show warnings and errors $ cv core:check-req -we '); - $this->configureBootOptions('none'); + } + + public function getBootOptions(): array { + return ['default' => 'none', 'allow' => ['none']]; } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/CoreInstallCommand.php b/src/Command/CoreInstallCommand.php index f4c38cd0..5649932d 100644 --- a/src/Command/CoreInstallCommand.php +++ b/src/Command/CoreInstallCommand.php @@ -50,7 +50,10 @@ protected function configure() { $ cv core:install --model=extras.opt-in.versionCheck=1 $ cv core:install -m extras.opt-in.versionCheck=1 '); - $this->configureBootOptions('none'); + } + + public function getBootOptions(): array { + return ['default' => 'none', 'allow' => ['none']]; } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/CoreUninstallCommand.php b/src/Command/CoreUninstallCommand.php index 343db33f..f8c4dbdd 100644 --- a/src/Command/CoreUninstallCommand.php +++ b/src/Command/CoreUninstallCommand.php @@ -29,7 +29,10 @@ protected function configure() { TIP: If you have a special system configuration, it may help to pass the same options for "core:uninstall" as the preceding "core:install". '); - $this->configureBootOptions('none'); + } + + public function getBootOptions(): array { + return ['default' => 'none', 'allow' => ['none']]; } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/DebugContainerCommand.php b/src/Command/DebugContainerCommand.php index 2d2fba3b..4f9a63ef 100644 --- a/src/Command/DebugContainerCommand.php +++ b/src/Command/DebugContainerCommand.php @@ -1,7 +1,6 @@ configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/DebugDispatcherCommand.php b/src/Command/DebugDispatcherCommand.php index 1bbb6192..c094c672 100644 --- a/src/Command/DebugDispatcherCommand.php +++ b/src/Command/DebugDispatcherCommand.php @@ -1,7 +1,6 @@ configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/EditCommand.php b/src/Command/EditCommand.php index 6e5f3549..7b9a3274 100644 --- a/src/Command/EditCommand.php +++ b/src/Command/EditCommand.php @@ -9,14 +9,11 @@ use Civi\Cv\Config; use Civi\Cv\Encoder; use Civi\Cv\Util\CliEditor; -use Civi\Cv\Util\BootTrait; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class EditCommand extends BaseCommand { - use BootTrait; - /** * @var \Civi\Cv\Util\CliEditor */ @@ -26,7 +23,6 @@ protected function configure() { $this ->setName('vars:edit') ->setDescription('Edit configuration values for this build'); - $this->configureBootOptions(); } public function __construct($name = NULL) { diff --git a/src/Command/EvalCommand.php b/src/Command/EvalCommand.php index 69e9d0fb..4cb8e4d6 100644 --- a/src/Command/EvalCommand.php +++ b/src/Command/EvalCommand.php @@ -2,7 +2,6 @@ namespace Civi\Cv\Command; use Civi\Cv\Encoder; -use Civi\Cv\Util\BootTrait; use Civi\Cv\Util\StructuredOutputTrait; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -10,7 +9,6 @@ class EvalCommand extends BaseCommand { - use BootTrait; use StructuredOutputTrait; protected function configure() { @@ -37,7 +35,6 @@ protected function configure() { NOTE: To change the default output format, set CV_OUTPUT. '); - $this->configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/ExtensionDisableCommand.php b/src/Command/ExtensionDisableCommand.php index c5991d03..b5ad7b71 100644 --- a/src/Command/ExtensionDisableCommand.php +++ b/src/Command/ExtensionDisableCommand.php @@ -35,7 +35,6 @@ protected function configure() { This subcommand does not output parseable data. For parseable output, consider using `cv api extension.disable`. '); - $this->configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/ExtensionDownloadCommand.php b/src/Command/ExtensionDownloadCommand.php index cb62a723..1921db30 100644 --- a/src/Command/ExtensionDownloadCommand.php +++ b/src/Command/ExtensionDownloadCommand.php @@ -58,7 +58,6 @@ protected function configure() { consider using `cv api extension.install`. '); parent::configureRepoOptions(); - $this->configureBootOptions(); } protected function initialize(InputInterface $input, OutputInterface $output) { diff --git a/src/Command/ExtensionEnableCommand.php b/src/Command/ExtensionEnableCommand.php index 201cba29..26f57957 100644 --- a/src/Command/ExtensionEnableCommand.php +++ b/src/Command/ExtensionEnableCommand.php @@ -38,7 +38,6 @@ protected function configure() { This subcommand does not output parseable data. For parseable output, consider using `cv api extension.install`. '); - $this->configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/ExtensionListCommand.php b/src/Command/ExtensionListCommand.php index a26475d6..78dcc6b5 100644 --- a/src/Command/ExtensionListCommand.php +++ b/src/Command/ExtensionListCommand.php @@ -58,7 +58,6 @@ protected function configure() { name ("foobar"). However, short names are not strongly guaranteed. '); parent::configureRepoOptions(); - $this->configureBootOptions(); } protected function initialize(InputInterface $input, OutputInterface $output) { diff --git a/src/Command/ExtensionUninstallCommand.php b/src/Command/ExtensionUninstallCommand.php index 3a740713..a90dc948 100644 --- a/src/Command/ExtensionUninstallCommand.php +++ b/src/Command/ExtensionUninstallCommand.php @@ -35,7 +35,6 @@ protected function configure() { This subcommand does not output parseable data. For parseable output, consider using `cv api extension.uninstall`. '); - $this->configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/ExtensionUpgradeDbCommand.php b/src/Command/ExtensionUpgradeDbCommand.php index da593d84..07539111 100644 --- a/src/Command/ExtensionUpgradeDbCommand.php +++ b/src/Command/ExtensionUpgradeDbCommand.php @@ -32,7 +32,6 @@ protected function configure() { This command is now deprecated. Use "cv upgrade:db" to perform upgrades for core and/or extensions. '); - $this->configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/FillCommand.php b/src/Command/FillCommand.php index fb332d27..cc62263a 100644 --- a/src/Command/FillCommand.php +++ b/src/Command/FillCommand.php @@ -4,15 +4,12 @@ use Civi\Cv\Config; use Civi\Cv\Encoder; use Civi\Cv\SiteConfigReader; -use Civi\Cv\Util\BootTrait; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; class FillCommand extends BaseCommand { - use BootTrait; - protected $fields; /** @@ -25,7 +22,6 @@ protected function configure() { ->setName('vars:fill') ->setDescription('Generate a configuration file for any missing site data') ->addOption('file', NULL, InputOption::VALUE_REQUIRED, 'Read existing configuration from a file'); - $this->configureBootOptions(); } public function __construct($name = NULL) { diff --git a/src/Command/FlushCommand.php b/src/Command/FlushCommand.php index e6db6843..f0aab4f8 100644 --- a/src/Command/FlushCommand.php +++ b/src/Command/FlushCommand.php @@ -1,7 +1,6 @@ setName('flush') @@ -20,7 +17,6 @@ protected function configure() { ->setHelp(' Flush system caches '); - $this->configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/HttpCommand.php b/src/Command/HttpCommand.php index c79b78ce..af44f2e5 100644 --- a/src/Command/HttpCommand.php +++ b/src/Command/HttpCommand.php @@ -43,7 +43,6 @@ protected function configure() { enabling the extension. The extra I/O may influence some scripted use-cases. '); - $this->configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/PathCommand.php b/src/Command/PathCommand.php index 20348d85..183e16d3 100644 --- a/src/Command/PathCommand.php +++ b/src/Command/PathCommand.php @@ -55,7 +55,6 @@ protected function configure() { Example: Lookup multiple items cv path -x cividiscount/info.xml -x flexmailer/info.xml -d \'[civicrm.root]/civicrm-version.php\' '); - $this->configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/PipeCommand.php b/src/Command/PipeCommand.php index 25d4140f..8668d683 100644 --- a/src/Command/PipeCommand.php +++ b/src/Command/PipeCommand.php @@ -1,15 +1,12 @@ configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/ScriptCommand.php b/src/Command/ScriptCommand.php index 08e58f01..21943788 100644 --- a/src/Command/ScriptCommand.php +++ b/src/Command/ScriptCommand.php @@ -2,15 +2,12 @@ namespace Civi\Cv\Command; use Civi\Cv\Util\Filesystem; -use Civi\Cv\Util\BootTrait; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class ScriptCommand extends BaseCommand { - use BootTrait; - protected function configure() { $this ->setName('php:script') @@ -18,7 +15,6 @@ protected function configure() { ->setDescription('Execute a PHP script') ->addArgument('script', InputArgument::REQUIRED) ->addArgument('scriptArguments', InputArgument::IS_ARRAY, 'Optional arguments to pass to the script as $argv'); - $this->configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/SettingGetCommand.php b/src/Command/SettingGetCommand.php index 36cbe2c7..a2cd4e03 100644 --- a/src/Command/SettingGetCommand.php +++ b/src/Command/SettingGetCommand.php @@ -1,7 +1,6 @@ configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/SettingRevertCommand.php b/src/Command/SettingRevertCommand.php index 489a3e92..3a6cdd86 100644 --- a/src/Command/SettingRevertCommand.php +++ b/src/Command/SettingRevertCommand.php @@ -1,7 +1,6 @@ configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/SettingSetCommand.php b/src/Command/SettingSetCommand.php index 4db84c66..f3620019 100644 --- a/src/Command/SettingSetCommand.php +++ b/src/Command/SettingSetCommand.php @@ -1,7 +1,6 @@ configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/ShowCommand.php b/src/Command/ShowCommand.php index a598aa2f..2a6ce619 100644 --- a/src/Command/ShowCommand.php +++ b/src/Command/ShowCommand.php @@ -2,14 +2,12 @@ namespace Civi\Cv\Command; use Civi\Cv\SiteConfigReader; -use Civi\Cv\Util\BootTrait; use Civi\Cv\Util\StructuredOutputTrait; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class ShowCommand extends BaseCommand { - use BootTrait; use StructuredOutputTrait; protected function configure() { @@ -17,7 +15,6 @@ protected function configure() { ->setName('vars:show') ->setDescription('Show the configuration of the local CiviCRM installation') ->configureOutputOptions(); - $this->configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/SqlCliCommand.php b/src/Command/SqlCliCommand.php index f43b259b..9bd993e4 100644 --- a/src/Command/SqlCliCommand.php +++ b/src/Command/SqlCliCommand.php @@ -3,15 +3,12 @@ use Civi\Cv\Util\Datasource; use Civi\Cv\Util\Process; -use Civi\Cv\Util\BootTrait; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; class SqlCliCommand extends BaseCommand { - use BootTrait; - protected function configure() { $this ->setName('sql') @@ -38,7 +35,6 @@ protected function configure() { #ENV[FOO] Produces the numerical value of FOO (or fails) !ENV[FOO] Produces the raw, unescaped string version of FOO "); - $this->configureBootOptions(); } protected function initialize(InputInterface $input, OutputInterface $output) { diff --git a/src/Command/UpgradeDbCommand.php b/src/Command/UpgradeDbCommand.php index c1f98f89..7b5386d3 100644 --- a/src/Command/UpgradeDbCommand.php +++ b/src/Command/UpgradeDbCommand.php @@ -1,7 +1,6 @@ configureBootOptions(); } /** diff --git a/src/Command/UpgradeGetCommand.php b/src/Command/UpgradeGetCommand.php index 56f358a3..a8f87d10 100644 --- a/src/Command/UpgradeGetCommand.php +++ b/src/Command/UpgradeGetCommand.php @@ -1,7 +1,6 @@ configureBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/UrlCommand.php b/src/Command/UrlCommand.php index b712836a..2ce822a0 100644 --- a/src/Command/UrlCommand.php +++ b/src/Command/UrlCommand.php @@ -66,7 +66,6 @@ protected function configure() { enabling the extension. The extra I/O may influence some scripted use-cases. '); - $this->configureBootOptions(); } protected function initialize(InputInterface $input, OutputInterface $output) { diff --git a/src/Util/SetupCommandTrait.php b/src/Util/SetupCommandTrait.php index 6126be06..e7022d0a 100644 --- a/src/Util/SetupCommandTrait.php +++ b/src/Util/SetupCommandTrait.php @@ -15,7 +15,6 @@ * civicrm-setup framework. */ trait SetupCommandTrait { - use BootTrait; /** * Register any CLI options which affect the initialization of the From 5c7f9a6c1aeab0a91558fe2a3231d3e0060e0300 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 23 Sep 2024 23:03:01 -0700 Subject: [PATCH 10/90] Promote boot options from BootTrait to BaseApplication --- lib/src/BaseApplication.php | 7 +++++++ lib/src/Util/BootTrait.php | 13 ++++++++----- src/Command/BaseCommand.php | 25 +++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/lib/src/BaseApplication.php b/lib/src/BaseApplication.php index 2f51677a..d93d0b5e 100644 --- a/lib/src/BaseApplication.php +++ b/lib/src/BaseApplication.php @@ -2,6 +2,7 @@ namespace Civi\Cv; use Civi\Cv\Util\AliasFilter; +use Civi\Cv\Util\BootTrait; use Civi\Cv\Util\CvArgvInput; use LesserEvil\ShellVerbosityIsEvil; use Symfony\Component\Console\Input\InputInterface; @@ -67,6 +68,12 @@ protected function getDefaultInputDefinition() { $definition = parent::getDefaultInputDefinition(); $definition->addOption(new InputOption('cwd', NULL, InputOption::VALUE_REQUIRED, 'If specified, use the given directory as working directory.')); $definition->addOption(new InputOption('site-alias', NULL, InputOption::VALUE_REQUIRED, 'Load site connection data based on its alias')); + + $c = new class() { + use BootTrait; + }; + $c->configureDefinition($definition); + return $definition; } diff --git a/lib/src/Util/BootTrait.php b/lib/src/Util/BootTrait.php index 47ed62f4..70360f67 100644 --- a/lib/src/Util/BootTrait.php +++ b/lib/src/Util/BootTrait.php @@ -16,11 +16,14 @@ */ trait BootTrait { - public function configureBootOptions($defaultLevel = 'full|cms-full') { - $this->addOption('level', NULL, InputOption::VALUE_REQUIRED, 'Bootstrap level (none,classloader,settings,full,cms-only,cms-full)', $defaultLevel); - $this->addOption('hostname', NULL, InputOption::VALUE_REQUIRED, 'Hostname (for a multisite system)'); - $this->addOption('test', 't', InputOption::VALUE_NONE, 'Bootstrap the test database (CIVICRM_UF=UnitTests)'); - $this->addOption('user', 'U', InputOption::VALUE_REQUIRED, 'CMS user'); + /** + * @internal + */ + public function configureDefinition($definition, $defaultLevel = 'full|cms-full') { + $definition->addOption(new InputOption('level', NULL, InputOption::VALUE_REQUIRED, 'Bootstrap level (none,classloader,settings,full,cms-only,cms-full)', $defaultLevel)); + $definition->addOption(new InputOption('hostname', NULL, InputOption::VALUE_REQUIRED, 'Hostname (for a multisite system)')); + $definition->addOption(new InputOption('test', 't', InputOption::VALUE_NONE, 'Bootstrap the test database (CIVICRM_UF=UnitTests)')); + $definition->addOption(new InputOption('user', 'U', InputOption::VALUE_REQUIRED, 'CMS user')); } public function boot(InputInterface $input, OutputInterface $output) { diff --git a/src/Command/BaseCommand.php b/src/Command/BaseCommand.php index 99ca6681..7f352a07 100644 --- a/src/Command/BaseCommand.php +++ b/src/Command/BaseCommand.php @@ -1,6 +1,7 @@ 'full|cms-full', + 'allow' => ['full|cms-full', 'full', 'cms-full', 'settings', 'classloader', 'cms-only', 'none'], + ]; + } + + public function mergeApplicationDefinition($mergeArgs = TRUE) { + parent::mergeApplicationDefinition($mergeArgs); + $bootOptions = $this->getBootOptions(); + $this->getDefinition()->getOption('level')->setDefault($bootOptions['default']); + } /** * @param \Symfony\Component\Console\Input\InputInterface $input * @param \Symfony\Component\Console\Output\OutputInterface $output */ protected function initialize(InputInterface $input, OutputInterface $output) { + $bootOptions = $this->getBootOptions(); + if (!in_array($input->getOption('level'), $bootOptions['allow'])) { + throw new \LogicException(sprintf("Command called with with level (%s) but only accepts levels (%s)", + $input->getOption('level'), implode(', ', $bootOptions['allow']))); + } + + if (!$this->isBooted() && ($bootOptions['auto'] ?? TRUE)) { + $this->boot($input, $output); + } + parent::initialize($input, $output); $this->runOptionCallbacks($input, $output); } From ae1ed440417b93076aa1e34b7fdc1f4978b33188 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 24 Sep 2024 00:28:24 -0700 Subject: [PATCH 11/90] Don't explicitly call boot() --- src/Command/AngularHtmlListCommand.php | 1 - src/Command/AngularHtmlShowCommand.php | 1 - src/Command/AngularModuleListCommand.php | 1 - src/Command/Api4Command.php | 2 -- src/Command/ApiBatchCommand.php | 1 - src/Command/ApiCommand.php | 2 -- src/Command/BootCommand.php | 2 -- src/Command/CliCommand.php | 2 -- src/Command/DebugContainerCommand.php | 6 ++++-- src/Command/DebugDispatcherCommand.php | 6 ++++-- src/Command/EditCommand.php | 2 -- src/Command/EvalCommand.php | 2 -- src/Command/ExtensionDisableCommand.php | 1 - src/Command/ExtensionDownloadCommand.php | 11 ++++------- src/Command/ExtensionEnableCommand.php | 2 -- src/Command/ExtensionListCommand.php | 12 +++++------- src/Command/ExtensionUninstallCommand.php | 1 - src/Command/ExtensionUpgradeDbCommand.php | 6 ++++-- src/Command/FillCommand.php | 1 - src/Command/FlushCommand.php | 7 ++++++- src/Command/HttpCommand.php | 2 -- src/Command/PathCommand.php | 2 -- src/Command/PipeCommand.php | 1 - src/Command/ScriptCommand.php | 4 ++++ src/Command/SettingGetCommand.php | 2 -- src/Command/SettingRevertCommand.php | 1 - src/Command/SettingSetCommand.php | 1 - src/Command/ShowCommand.php | 1 - src/Command/SqlCliCommand.php | 2 -- src/Command/UpgradeDbCommand.php | 8 +++----- src/Command/UpgradeGetCommand.php | 4 ++++ src/Command/UrlCommand.php | 9 +++------ 32 files changed, 41 insertions(+), 65 deletions(-) diff --git a/src/Command/AngularHtmlListCommand.php b/src/Command/AngularHtmlListCommand.php index d0713348..84657424 100644 --- a/src/Command/AngularHtmlListCommand.php +++ b/src/Command/AngularHtmlListCommand.php @@ -35,7 +35,6 @@ protected function configure() { } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->boot($input, $output); if (!$input->getOption('user')) { $output->getErrorOutput()->writeln("For a full list, try passing --user=[username]."); } diff --git a/src/Command/AngularHtmlShowCommand.php b/src/Command/AngularHtmlShowCommand.php index 9114b5ae..d3bd80d8 100644 --- a/src/Command/AngularHtmlShowCommand.php +++ b/src/Command/AngularHtmlShowCommand.php @@ -38,7 +38,6 @@ protected function configure() { } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->boot($input, $output); if (!$input->getOption('user')) { $output->getErrorOutput()->writeln("For a full list, try passing --user=[username]."); } diff --git a/src/Command/AngularModuleListCommand.php b/src/Command/AngularModuleListCommand.php index 925018d4..56781bce 100644 --- a/src/Command/AngularModuleListCommand.php +++ b/src/Command/AngularModuleListCommand.php @@ -37,7 +37,6 @@ protected function configure() { } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->boot($input, $output); if (!$input->getOption('user')) { $output->getErrorOutput()->writeln("For a full list, try passing --user=[username]."); } diff --git a/src/Command/Api4Command.php b/src/Command/Api4Command.php index 4a3668ba..a5f045b8 100644 --- a/src/Command/Api4Command.php +++ b/src/Command/Api4Command.php @@ -128,8 +128,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $I = ''; $_I = ''; - $this->boot($input, $output); - if (!function_exists('civicrm_api4')) { throw new \RuntimeException("Please enable APIv4 before running APIv4 commands."); } diff --git a/src/Command/ApiBatchCommand.php b/src/Command/ApiBatchCommand.php index c8a4c3e7..49bd8786 100644 --- a/src/Command/ApiBatchCommand.php +++ b/src/Command/ApiBatchCommand.php @@ -60,7 +60,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Other formats may not work with the fgets() loop. throw new \Exception("api:batch only supports JSON dialog"); } - $this->boot($input, $output); $addDefault = function($v) { $this->defaults = \CRM_Utils_Array::crmArrayMerge($v, $this->defaults); diff --git a/src/Command/ApiCommand.php b/src/Command/ApiCommand.php index 4ceb348b..6a73dbcd 100644 --- a/src/Command/ApiCommand.php +++ b/src/Command/ApiCommand.php @@ -53,8 +53,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $I = ''; $_I = ''; - $this->boot($input, $output); - list($entity, $action) = explode('.', $input->getArgument('Entity.action')); $params = $this->parseParams($input); diff --git a/src/Command/BootCommand.php b/src/Command/BootCommand.php index beac59de..c0350911 100644 --- a/src/Command/BootCommand.php +++ b/src/Command/BootCommand.php @@ -13,8 +13,6 @@ protected function configure() { } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->boot($input, $output); - switch ($input->getOption('level')) { case 'classloader': $code = sprintf('require_once %s . "/CRM/Core/ClassLoader.php";', var_export(rtrim($GLOBALS["civicrm_root"], '/'), 1)) diff --git a/src/Command/CliCommand.php b/src/Command/CliCommand.php index 789c2d5c..eb3fbd7c 100644 --- a/src/Command/CliCommand.php +++ b/src/Command/CliCommand.php @@ -18,8 +18,6 @@ protected function configure() { } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->boot($input, $output); - $cv = new Application(); $sh = new \Psy\Shell(); $sh->addCommands($cv->createCommands()); diff --git a/src/Command/DebugContainerCommand.php b/src/Command/DebugContainerCommand.php index 4f9a63ef..6824751c 100644 --- a/src/Command/DebugContainerCommand.php +++ b/src/Command/DebugContainerCommand.php @@ -33,11 +33,13 @@ protected function configure() { '); } - protected function execute(InputInterface $input, OutputInterface $output): int { + protected function initialize(InputInterface $input, OutputInterface $output) { define('CIVICRM_CONTAINER_CACHE', 'never'); $output->getErrorOutput()->writeln('The debug command ignores the container cache.'); - $this->boot($input, $output); + parent::initialize($input, $output); + } + protected function execute(InputInterface $input, OutputInterface $output): int { $c = $this->getInspectableContainer($input); $filterPat = $input->getArgument('name'); diff --git a/src/Command/DebugDispatcherCommand.php b/src/Command/DebugDispatcherCommand.php index c094c672..a7a2500d 100644 --- a/src/Command/DebugDispatcherCommand.php +++ b/src/Command/DebugDispatcherCommand.php @@ -27,11 +27,13 @@ protected function configure() { '); } - protected function execute(InputInterface $input, OutputInterface $output): int { + protected function initialize(InputInterface $input, OutputInterface $output) { define('CIVICRM_CONTAINER_CACHE', 'never'); $output->getErrorOutput()->writeln('The debug command ignores the container cache.'); - $this->boot($input, $output); + parent::initialize($input, $output); + } + protected function execute(InputInterface $input, OutputInterface $output): int { $container = \Civi::container(); /* diff --git a/src/Command/EditCommand.php b/src/Command/EditCommand.php index 7b9a3274..d1daa858 100644 --- a/src/Command/EditCommand.php +++ b/src/Command/EditCommand.php @@ -43,8 +43,6 @@ public function __construct($name = NULL) { } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->boot($input, $output); - $config = Config::read(); $oldSiteData = empty($config['sites'][CIVICRM_SETTINGS_PATH]) ? array() : $config['sites'][CIVICRM_SETTINGS_PATH]; $oldJson = Encoder::encode($oldSiteData, 'json-pretty'); diff --git a/src/Command/EvalCommand.php b/src/Command/EvalCommand.php index 4cb8e4d6..2c5b960a 100644 --- a/src/Command/EvalCommand.php +++ b/src/Command/EvalCommand.php @@ -38,8 +38,6 @@ protected function configure() { } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->boot($input, $output); - if ($input->getOption('out') === 'auto') { $hasReturn = preg_match('/^\s*return[ \t\r\n]/', $input->getArgument('code')) || preg_match('/[;\{]\s*return[ \t\r\n]/', $input->getArgument('code')); diff --git a/src/Command/ExtensionDisableCommand.php b/src/Command/ExtensionDisableCommand.php index b5ad7b71..fe3c2975 100644 --- a/src/Command/ExtensionDisableCommand.php +++ b/src/Command/ExtensionDisableCommand.php @@ -38,7 +38,6 @@ protected function configure() { } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->boot($input, $output); [$foundKeys, $missingKeys] = $this->parseKeys($input, $output); // Uninstall what's recognized or what looks like an ext key. diff --git a/src/Command/ExtensionDownloadCommand.php b/src/Command/ExtensionDownloadCommand.php index 1921db30..2cef9853 100644 --- a/src/Command/ExtensionDownloadCommand.php +++ b/src/Command/ExtensionDownloadCommand.php @@ -65,18 +65,15 @@ protected function initialize(InputInterface $input, OutputInterface $output) { $input->setOption('level', 'none'); $input->setOption('no-install', TRUE); } - parent::initialize($input, $output); - } - - protected function execute(InputInterface $input, OutputInterface $output): int { - $fs = new Filesystem(); - if ($extRepoUrl = $this->parseRepoUrl($input)) { global $civicrm_setting; $civicrm_setting['Extension Preferences']['ext_repo_url'] = $extRepoUrl; } + parent::initialize($input, $output); + } - $this->boot($input, $output); + protected function execute(InputInterface $input, OutputInterface $output): int { + $fs = new Filesystem(); if ($input->getOption('to') && !$fs->isAbsolutePath($input->getOption('to'))) { throw new \RuntimeException("The --to argument requires an absolute path."); diff --git a/src/Command/ExtensionEnableCommand.php b/src/Command/ExtensionEnableCommand.php index 26f57957..2ba86313 100644 --- a/src/Command/ExtensionEnableCommand.php +++ b/src/Command/ExtensionEnableCommand.php @@ -41,8 +41,6 @@ protected function configure() { } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->boot($input, $output); - // Refresh extensions if (a) ---refresh enabled or (b) there's a cache-miss. $refresh = $input->getOption('refresh') ? 'yes' : 'auto'; // $refresh = OptionalOption::parse(array('--refresh', '-r'), 'auto', 'yes'); diff --git a/src/Command/ExtensionListCommand.php b/src/Command/ExtensionListCommand.php index 78dcc6b5..218527c9 100644 --- a/src/Command/ExtensionListCommand.php +++ b/src/Command/ExtensionListCommand.php @@ -61,6 +61,11 @@ protected function configure() { } protected function initialize(InputInterface $input, OutputInterface $output) { + if ($extRepoUrl = $this->parseRepoUrl($input)) { + global $civicrm_setting; + $civicrm_setting['Extension Preferences']['ext_repo_url'] = $extRepoUrl; + } + parent::initialize($input, $output); // We apply different defaults for the 'columns' list depending on the output medium. @@ -84,13 +89,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int [$local, $remote] = $this->parseLocalRemote($input); - if ($extRepoUrl = $this->parseRepoUrl($input)) { - global $civicrm_setting; - $civicrm_setting['Extension Preferences']['ext_repo_url'] = $extRepoUrl; - } - - $this->boot($input, $output); - if ($remote) { $output->writeln("Using extension feed \"" . \CRM_Extension_System::singleton()->getBrowser()->getRepositoryUrl() . "\"", $wo); } diff --git a/src/Command/ExtensionUninstallCommand.php b/src/Command/ExtensionUninstallCommand.php index a90dc948..74e77852 100644 --- a/src/Command/ExtensionUninstallCommand.php +++ b/src/Command/ExtensionUninstallCommand.php @@ -38,7 +38,6 @@ protected function configure() { } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->boot($input, $output); [$foundKeys, $missingKeys] = $this->parseKeys($input, $output); // Uninstall what's recognized or what looks like an ext key. diff --git a/src/Command/ExtensionUpgradeDbCommand.php b/src/Command/ExtensionUpgradeDbCommand.php index 07539111..13e1b6cb 100644 --- a/src/Command/ExtensionUpgradeDbCommand.php +++ b/src/Command/ExtensionUpgradeDbCommand.php @@ -34,10 +34,12 @@ protected function configure() { '); } - protected function execute(InputInterface $input, OutputInterface $output): int { + protected function initialize(InputInterface $input, OutputInterface $output) { $output->writeln("WARNING: \"ext:upgrade-db\" is deprecated. Use the main \"updb\" command instead."); - $this->boot($input, $output); + parent::initialize($input, $output); + } + protected function execute(InputInterface $input, OutputInterface $output): int { $output->writeln("Applying database upgrades from extensions"); $result = VerboseApi::callApi3Success('Extension', 'upgrade', array()); if (!empty($result['is_error'])) { diff --git a/src/Command/FillCommand.php b/src/Command/FillCommand.php index cc62263a..91eb22de 100644 --- a/src/Command/FillCommand.php +++ b/src/Command/FillCommand.php @@ -55,7 +55,6 @@ public function __construct($name = NULL) { } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->boot($input, $output); if (!$input->getOption('file')) { $reader = new SiteConfigReader(CIVICRM_SETTINGS_PATH); $liveData = $reader->compile(array('buildkit', 'home', 'active')); diff --git a/src/Command/FlushCommand.php b/src/Command/FlushCommand.php index f0aab4f8..6b3cab44 100644 --- a/src/Command/FlushCommand.php +++ b/src/Command/FlushCommand.php @@ -19,10 +19,15 @@ protected function configure() { '); } - protected function execute(InputInterface $input, OutputInterface $output): int { + protected function initialize(InputInterface $input, OutputInterface $output) { // The main reason we have this as separate command -- so we can ignore // stale class-references that might be retained by the container cache. define('CIVICRM_CONTAINER_CACHE', 'never'); + + parent::initialize($input, $output); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { $this->boot($input, $output); $params = array(); diff --git a/src/Command/HttpCommand.php b/src/Command/HttpCommand.php index af44f2e5..c2ec6a78 100644 --- a/src/Command/HttpCommand.php +++ b/src/Command/HttpCommand.php @@ -46,8 +46,6 @@ protected function configure() { } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->boot($input, $output); - $method = $input->getOption('request'); $data = $this->parseRequestData($input); $headers = $this->parseRequestHeaders($input); diff --git a/src/Command/PathCommand.php b/src/Command/PathCommand.php index 183e16d3..dcdfe139 100644 --- a/src/Command/PathCommand.php +++ b/src/Command/PathCommand.php @@ -58,8 +58,6 @@ protected function configure() { } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->boot($input, $output); - if (!$input->getOption('ext') && !$input->getOption('config') && !$input->getOption('dynamic')) { $output->getErrorOutput()->writeln("No paths specified. Must use -x, -c, or -d. (See also: cv path -h)"); return 1; diff --git a/src/Command/PipeCommand.php b/src/Command/PipeCommand.php index 8668d683..7e2bb51d 100644 --- a/src/Command/PipeCommand.php +++ b/src/Command/PipeCommand.php @@ -46,7 +46,6 @@ protected function configure() { } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->boot($input, $output); if (!is_callable(['Civi', 'pipe'])) { fwrite(STDERR, "This version of CiviCRM does not include Civi::pipe() support.\n"); return 1; diff --git a/src/Command/ScriptCommand.php b/src/Command/ScriptCommand.php index 21943788..5f8b71ca 100644 --- a/src/Command/ScriptCommand.php +++ b/src/Command/ScriptCommand.php @@ -17,6 +17,10 @@ protected function configure() { ->addArgument('scriptArguments', InputArgument::IS_ARRAY, 'Optional arguments to pass to the script as $argv'); } + public function getBootOptions(): array { + return parent::getBootOptions() + ['auto' => FALSE]; + } + protected function execute(InputInterface $input, OutputInterface $output): int { $fs = new Filesystem(); $origScript = $fs->toAbsolutePath($input->getArgument('script')); diff --git a/src/Command/SettingGetCommand.php b/src/Command/SettingGetCommand.php index a2cd4e03..9af13a4a 100644 --- a/src/Command/SettingGetCommand.php +++ b/src/Command/SettingGetCommand.php @@ -69,8 +69,6 @@ protected function configure() { } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->boot($input, $output); - $filter = $this->createSettingFilter($input->getArgument('name')); $result = []; diff --git a/src/Command/SettingRevertCommand.php b/src/Command/SettingRevertCommand.php index 3a6cdd86..c1ba4766 100644 --- a/src/Command/SettingRevertCommand.php +++ b/src/Command/SettingRevertCommand.php @@ -60,7 +60,6 @@ protected function configure() { } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->boot($input, $output); $errorOutput = is_callable([$output, 'getErrorOutput']) ? $output->getErrorOutput() : $output; $filter = $this->createSettingFilter($input->getArgument('name')); diff --git a/src/Command/SettingSetCommand.php b/src/Command/SettingSetCommand.php index f3620019..14f15f5f 100644 --- a/src/Command/SettingSetCommand.php +++ b/src/Command/SettingSetCommand.php @@ -88,7 +88,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $I = ''; $_I = ''; - $this->boot($input, $output); $errorOutput = is_callable([$output, 'getErrorOutput']) ? $output->getErrorOutput() : $output; $result = []; diff --git a/src/Command/ShowCommand.php b/src/Command/ShowCommand.php index 2a6ce619..f33b3401 100644 --- a/src/Command/ShowCommand.php +++ b/src/Command/ShowCommand.php @@ -18,7 +18,6 @@ protected function configure() { } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->boot($input, $output); $reader = new SiteConfigReader(CIVICRM_SETTINGS_PATH); $data = $reader->compile(array('buildkit', 'home', 'active')); $this->sendResult($input, $output, $data); diff --git a/src/Command/SqlCliCommand.php b/src/Command/SqlCliCommand.php index 9bd993e4..65b29933 100644 --- a/src/Command/SqlCliCommand.php +++ b/src/Command/SqlCliCommand.php @@ -44,8 +44,6 @@ protected function initialize(InputInterface $input, OutputInterface $output) { } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->boot($input, $output); - $datasource = new Datasource(); $datasource->loadFromCiviDSN($this->pickDsn($input->getOption('target'))); diff --git a/src/Command/UpgradeDbCommand.php b/src/Command/UpgradeDbCommand.php index 7b5386d3..37c64899 100644 --- a/src/Command/UpgradeDbCommand.php +++ b/src/Command/UpgradeDbCommand.php @@ -49,15 +49,13 @@ protected function configure() { protected function initialize(InputInterface $input, OutputInterface $output) { $this->input = $input; $this->output = $output; - parent::initialize($input, $output); - } - - protected function execute(InputInterface $input, OutputInterface $output): int { if (!defined('CIVICRM_UPGRADE_ACTIVE')) { define('CIVICRM_UPGRADE_ACTIVE', 1); } - $this->boot($input, $output); + parent::initialize($input, $output); + } + protected function execute(InputInterface $input, OutputInterface $output): int { if (!ini_get('safe_mode')) { set_time_limit(0); } diff --git a/src/Command/UpgradeGetCommand.php b/src/Command/UpgradeGetCommand.php index a8f87d10..daae009d 100644 --- a/src/Command/UpgradeGetCommand.php +++ b/src/Command/UpgradeGetCommand.php @@ -39,6 +39,10 @@ protected function configure() { '); } + public function getBootOptions(): array { + return parent::getBootOptions() + ['auto' => FALSE]; + } + protected function execute(InputInterface $input, OutputInterface $output): int { $result = array(); $exitCode = 0; diff --git a/src/Command/UrlCommand.php b/src/Command/UrlCommand.php index 2ce822a0..c8dbbb9c 100644 --- a/src/Command/UrlCommand.php +++ b/src/Command/UrlCommand.php @@ -69,20 +69,17 @@ protected function configure() { } protected function initialize(InputInterface $input, OutputInterface $output) { - parent::initialize($input, $output); if ($input->getFirstArgument() === 'open') { $input->setOption('open', TRUE); } - } - - protected function execute(InputInterface $input, OutputInterface $output): int { if (in_array($input->getOption('out'), Encoder::getTabularFormats()) && !in_array($input->getOption('out'), Encoder::getFormats())) { $input->setOption('tabular', TRUE); } + parent::initialize($input, $output); + } - $this->boot($input, $output); - + protected function execute(InputInterface $input, OutputInterface $output): int { $rows = $this->createUrls($input, $output); if ($input->getOption('open')) { From 16999f7787a92ad3a6c71a7449a73dc4dcff1eb7 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 23 Sep 2024 23:23:19 -0700 Subject: [PATCH 12/90] lib/README.md --- lib/README.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/README.md b/lib/README.md index 5f79d6ce..c45a3305 100644 --- a/lib/README.md +++ b/lib/README.md @@ -78,11 +78,13 @@ For more info about `$options`, see the docblocks. ## Experimental API -Other classes are included, but their contracts are subject to change. - -A particularly interesting one is `BootTrait`. This requires `symfony/console`, and it is used by most `cv` subcommands -to achieve common behaviors: - -1. `BootTrait` defines certain CLI options (`--level`, `--user`, `--hostname`, etc). -2. `BootTrait` automatically decides between `Bootstrap.php` and `CmsBootstrap.php`. -3. `BootTrait` passes CLI options through to `Bootstrap.php` or `CmsBootstrap.php`. +Other classes are included, but their contracts are subject to change. These +include higher-level helpers for building Symfony Console apps that incorporate +Civi bootstrap behaviors. + +* `BootTrait` has previously suggested as an experimentally available API + (circa v0.3.44). It changed significantly (circa v0.3.56), where + `configureBootOptions()` was replaced by `$bootOptions`, `mergeDefaultBootDefinition()`, + and `mergeBootDefinition()`. +* As an alternative, consider the classes `BaseApplication` and `BaseCommand` if you aim + to build a tool using Symfony Console and Cv Lib. From 45247375faeab449470b8f93bd4313a5f037e74c Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 24 Sep 2024 00:58:36 -0700 Subject: [PATCH 13/90] Move BaseCommand from cv-app to cv-lib --- {src => lib/src}/Command/BaseCommand.php | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {src => lib/src}/Command/BaseCommand.php (100%) diff --git a/src/Command/BaseCommand.php b/lib/src/Command/BaseCommand.php similarity index 100% rename from src/Command/BaseCommand.php rename to lib/src/Command/BaseCommand.php From c100f6ffc6da72884c0c15a8c7f200facaf782e4 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 24 Sep 2024 09:14:01 -0700 Subject: [PATCH 14/90] (REF) Demonstrate $bootOptions['auto'] more clearly --- lib/src/Command/BaseCommand.php | 1 + src/Command/ScriptCommand.php | 2 +- src/Command/UpgradeGetCommand.php | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/src/Command/BaseCommand.php b/lib/src/Command/BaseCommand.php index 7f352a07..e60fd935 100644 --- a/lib/src/Command/BaseCommand.php +++ b/lib/src/Command/BaseCommand.php @@ -14,6 +14,7 @@ class BaseCommand extends Command { public function getBootOptions(): array { return [ + 'auto' => TRUE, 'default' => 'full|cms-full', 'allow' => ['full|cms-full', 'full', 'cms-full', 'settings', 'classloader', 'cms-only', 'none'], ]; diff --git a/src/Command/ScriptCommand.php b/src/Command/ScriptCommand.php index 5f8b71ca..41364e0f 100644 --- a/src/Command/ScriptCommand.php +++ b/src/Command/ScriptCommand.php @@ -18,7 +18,7 @@ protected function configure() { } public function getBootOptions(): array { - return parent::getBootOptions() + ['auto' => FALSE]; + return ['auto' => FALSE] + parent::getBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/src/Command/UpgradeGetCommand.php b/src/Command/UpgradeGetCommand.php index daae009d..6d2da3bc 100644 --- a/src/Command/UpgradeGetCommand.php +++ b/src/Command/UpgradeGetCommand.php @@ -40,7 +40,7 @@ protected function configure() { } public function getBootOptions(): array { - return parent::getBootOptions() + ['auto' => FALSE]; + return ['auto' => FALSE] + parent::getBootOptions(); } protected function execute(InputInterface $input, OutputInterface $output): int { From e1e3d45b09aaeb7347d39430fc80900585285cf8 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 24 Sep 2024 09:15:25 -0700 Subject: [PATCH 15/90] (NFC) BaseCommand --- lib/src/Command/BaseCommand.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/src/Command/BaseCommand.php b/lib/src/Command/BaseCommand.php index e60fd935..d615a0e4 100644 --- a/lib/src/Command/BaseCommand.php +++ b/lib/src/Command/BaseCommand.php @@ -7,6 +7,14 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +/** + * Cv's `BaseCommand` is a Symfony `Command` with support for bootstrapping CiviCRM/CMS. + * + * - From end-user POV, the command accepts options like --user, --level, --hostname. + * - From dev POV, the command allows you to implement `execute()` method without needing to + * explicitly boot Civi. + * - From dev POV, you may fine-tune command by changing the $bootOptions / getBootOptions(). + */ class BaseCommand extends Command { use OptionCallbackTrait; From 4cbdf55d2e8eb4dc1f9174c58556354c61ef20fc Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 24 Sep 2024 09:28:37 -0700 Subject: [PATCH 16/90] Move getBootOptions (etc) from BaseApplication to BootTrait --- lib/src/BaseApplication.php | 2 +- lib/src/Command/BaseCommand.php | 22 +--------- lib/src/Util/BootTrait.php | 72 ++++++++++++++++++++++++++++++++- 3 files changed, 74 insertions(+), 22 deletions(-) diff --git a/lib/src/BaseApplication.php b/lib/src/BaseApplication.php index d93d0b5e..dcc881e0 100644 --- a/lib/src/BaseApplication.php +++ b/lib/src/BaseApplication.php @@ -72,7 +72,7 @@ protected function getDefaultInputDefinition() { $c = new class() { use BootTrait; }; - $c->configureDefinition($definition); + $c->mergeDefaultBootDefinition($definition); return $definition; } diff --git a/lib/src/Command/BaseCommand.php b/lib/src/Command/BaseCommand.php index d615a0e4..e896cb81 100644 --- a/lib/src/Command/BaseCommand.php +++ b/lib/src/Command/BaseCommand.php @@ -20,18 +20,9 @@ class BaseCommand extends Command { use OptionCallbackTrait; use BootTrait; - public function getBootOptions(): array { - return [ - 'auto' => TRUE, - 'default' => 'full|cms-full', - 'allow' => ['full|cms-full', 'full', 'cms-full', 'settings', 'classloader', 'cms-only', 'none'], - ]; - } - public function mergeApplicationDefinition($mergeArgs = TRUE) { parent::mergeApplicationDefinition($mergeArgs); - $bootOptions = $this->getBootOptions(); - $this->getDefinition()->getOption('level')->setDefault($bootOptions['default']); + $this->mergeBootDefinition($this->getDefinition()); } /** @@ -39,16 +30,7 @@ public function mergeApplicationDefinition($mergeArgs = TRUE) { * @param \Symfony\Component\Console\Output\OutputInterface $output */ protected function initialize(InputInterface $input, OutputInterface $output) { - $bootOptions = $this->getBootOptions(); - if (!in_array($input->getOption('level'), $bootOptions['allow'])) { - throw new \LogicException(sprintf("Command called with with level (%s) but only accepts levels (%s)", - $input->getOption('level'), implode(', ', $bootOptions['allow']))); - } - - if (!$this->isBooted() && ($bootOptions['auto'] ?? TRUE)) { - $this->boot($input, $output); - } - + $this->autoboot($input, $output); parent::initialize($input, $output); $this->runOptionCallbacks($input, $output); } diff --git a/lib/src/Util/BootTrait.php b/lib/src/Util/BootTrait.php index 70360f67..300ec9fc 100644 --- a/lib/src/Util/BootTrait.php +++ b/lib/src/Util/BootTrait.php @@ -16,16 +16,70 @@ */ trait BootTrait { + /** + * Describe the expected bootstrap behaviors for this command. + * + * - For most commands, you will want to automatically boot CiviCRM/CMS. + * The default implementation will do this. + * - For some special commands (e.g. core-installer or PHP-script-runner), you may + * want more fine-grained control over when/how the system boots. + * + * @var array + */ + protected $bootOptions = [ + // Whether to automatically boot Civi during `initialize()` phase. + 'auto' => TRUE, + + // Default boot level. + 'default' => 'full|cms-full', + + // List of all boot levels that are allowed in this command. + 'allow' => ['full|cms-full', 'full', 'cms-full', 'settings', 'classloader', 'cms-only', 'none'], + ]; + /** * @internal */ - public function configureDefinition($definition, $defaultLevel = 'full|cms-full') { + public function mergeDefaultBootDefinition($definition, $defaultLevel = 'full|cms-full') { + // If we were only dealing with built-in/global commands, then these options could be defined at the command-level. + // However, we also have extension-based commands. The system will boot before we have a chance to discover them. + // By putting these options at the application level, we ensure they will be defined+used. $definition->addOption(new InputOption('level', NULL, InputOption::VALUE_REQUIRED, 'Bootstrap level (none,classloader,settings,full,cms-only,cms-full)', $defaultLevel)); $definition->addOption(new InputOption('hostname', NULL, InputOption::VALUE_REQUIRED, 'Hostname (for a multisite system)')); $definition->addOption(new InputOption('test', 't', InputOption::VALUE_NONE, 'Bootstrap the test database (CIVICRM_UF=UnitTests)')); $definition->addOption(new InputOption('user', 'U', InputOption::VALUE_REQUIRED, 'CMS user')); } + /** + * @internal + */ + public function mergeBootDefinition($definition) { + $bootOptions = $this->getBootOptions(); + $definition->getOption('level')->setDefault($bootOptions['default']); + } + + /** + * Evaluate the $bootOptions. + * + * - If we've already booted, do nothing. + * - If the configuration looks reasonable and if we haven't booted yet, then boot(). + * - If the configuration looks unreasonable, then abort. + */ + protected function autoboot(InputInterface $input, OutputInterface $output): void { + $bootOptions = $this->getBootOptions(); + if (!in_array($input->getOption('level'), $bootOptions['allow'])) { + throw new \LogicException(sprintf("Command called with with level (%s) but only accepts levels (%s)", + $input->getOption('level'), implode(', ', $bootOptions['allow']))); + } + + if (!$this->isBooted() && ($bootOptions['auto'] ?? TRUE)) { + $this->boot($input, $output); + } + } + + /** + * Start CiviCRM and/or CMS. Respect options like --user and --level. + */ public function boot(InputInterface $input, OutputInterface $output) { $logger = $this->bootLogger($output); $logger->debug('Start'); @@ -306,4 +360,20 @@ protected function assertBooted() { } } + /** + * @return array{auto: bool, default: string, allow: string[]} + */ + public function getBootOptions(): array { + return $this->bootOptions; + } + + /** + * @param array{auto: bool, default: string, allow: string[]} $bootOptions + * @return $this + */ + public function setBootOptions(array $bootOptions) { + $this->bootOptions = $bootOptions; + return $this; + } + } From 44d7c5b5078709b2f910edd627c9815b5d0f0d37 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 24 Sep 2024 11:20:51 -0700 Subject: [PATCH 17/90] BaseCommand - Suggest fluent style for plugin files --- doc/plugins.md | 17 ++--- lib/src/Util/BootTrait.php | 2 +- tests/Plugin/FluentHelloPluginTest.php | 39 +++++++++++ tests/Plugin/FluentHelloPluginTest/hello.php | 68 ++++++++++++++++++++ 4 files changed, 117 insertions(+), 9 deletions(-) create mode 100644 tests/Plugin/FluentHelloPluginTest.php create mode 100644 tests/Plugin/FluentHelloPluginTest/hello.php diff --git a/doc/plugins.md b/doc/plugins.md index 0235d803..144afd5d 100644 --- a/doc/plugins.md +++ b/doc/plugins.md @@ -8,6 +8,7 @@ Cv plugins are PHP files which register event listeners. // FILE: /etc/cv/plugin/hello-command.php use Civi\Cv\Cv; use CvDeps\Symfony\Component\Console\Input\InputInterface; +use CvDeps\Symfony\Component\Console\Input\InputArgument; use CvDeps\Symfony\Component\Console\Output\OutputInterface; use CvDeps\Symfony\Component\Console\Command\Command; @@ -16,15 +17,15 @@ if (empty($CV_PLUGIN['protocol']) || $CV_PLUGIN['protocol'] > 1) { } Cv::dispatcher()->addListener('cv.app.commands', function($e) { - $e['commands'][] = new class extends Command { - protected function configure() { - $this->setName('hello')->setDescription('Say a greeting'); - } - protected function execute(InputInterface $input, OutputInterface $output): int { - $output->writeln('Hello there!'); + + $e['commands'][] = (new \Civi\Cv\Command\BaseCommand('hello')) + ->setDescription('Say a greeting') + ->addArgument('name', InputArgument::REQUIRED, 'Name of the person to greet') + ->setCode(function($input, $output) { + $output->writeln('Hello, ' . $input->getArgument('name')); return 0; - } - }; + }); + }); ``` diff --git a/lib/src/Util/BootTrait.php b/lib/src/Util/BootTrait.php index 300ec9fc..2db48498 100644 --- a/lib/src/Util/BootTrait.php +++ b/lib/src/Util/BootTrait.php @@ -372,7 +372,7 @@ public function getBootOptions(): array { * @return $this */ public function setBootOptions(array $bootOptions) { - $this->bootOptions = $bootOptions; + $this->bootOptions = array_merge($this->bootOptions, $bootOptions); return $this; } diff --git a/tests/Plugin/FluentHelloPluginTest.php b/tests/Plugin/FluentHelloPluginTest.php new file mode 100644 index 00000000..6bc843d6 --- /dev/null +++ b/tests/Plugin/FluentHelloPluginTest.php @@ -0,0 +1,39 @@ +setEnv(['CV_PLUGIN_PATH' => preg_replace(';\.php$;', '', __FILE__)]); + return $process; + } + + public function testRun() { + $output = $this->cvOk('hello:normal'); + $this->assertMatchesRegularExpression('/Hey-yo world via parameter.*Hey-yo world via StyleInterface/s', $output); + } + + public function testRunWithName() { + $output = $this->cvOk('hello:normal Alice'); + $this->assertMatchesRegularExpression('/Hey-yo Alice via parameter.*Hey-yo Alice via StyleInterface/s', $output); + } + + public function testRun_noboot() { + $output = $this->cvOk('hello:noboot'); + $this->assertMatchesRegularExpression('/Hey-yo world via parameter.*Hey-yo world via StyleInterface/s', $output); + } + + public function testRunWithName_noboot() { + $output = $this->cvOk('hello:noboot Bob'); + $this->assertMatchesRegularExpression('/Hey-yo Bob via parameter.*Hey-yo Bob via StyleInterface/s', $output); + } + +} diff --git a/tests/Plugin/FluentHelloPluginTest/hello.php b/tests/Plugin/FluentHelloPluginTest/hello.php new file mode 100644 index 00000000..8c5a36b7 --- /dev/null +++ b/tests/Plugin/FluentHelloPluginTest/hello.php @@ -0,0 +1,68 @@ + 1) { + die("Expect CV_PLUGIN API v1"); +} + +if (!preg_match(';^[\w_-]+$;', $CV_PLUGIN['appName'])) { + throw new \RuntimeException("Invalid CV_PLUGIN[appName]" . json_encode($CV_PLUGIN['appName'])); +} + +if (!preg_match(';^([0-9x\.]+(-[\w-]+)?|UNKNOWN)$;', $CV_PLUGIN['appVersion'])) { + throw new \RuntimeException("Invalid CV_PLUGIN[appVersion]: " . json_encode($CV_PLUGIN['appVersion'])); +} + +if ($CV_PLUGIN['name'] !== 'hello') { + throw new \RuntimeException("Invalid CV_PLUGIN[name]"); +} +if (realpath($CV_PLUGIN['file']) !== realpath(__FILE__)) { + throw new \RuntimeException("Invalid CV_PLUGIN[file]"); +} + +Cv::dispatcher()->addListener('*.app.boot', function ($e) { + Cv::io()->writeln("Hey-yo during initial bootstrap!"); +}); + +Cv::dispatcher()->addListener('cv.app.commands', function ($e) { + + $e['commands'][] = (new BaseCommand('hello:normal')) + ->setDescription('Say a greeting') + ->addArgument('name') + ->setCode(function($input, $output) { + // ASSERT: With setCode(), it's OK to use un-hinted inputs. + if ($input->getArgument('name') !== Cv::input()->getArgument('name')) { + throw new \RuntimeException("Argument \"name\" is inconsistent!"); + } + if (!Civi\Core\Container::isContainerBooted()) { + throw new \LogicException("Container should have been booted by BaseCommand!"); + } + $name = $input->getArgument('name') ?: 'world'; + $output->writeln("Hey-yo $name via parameter!"); + Cv::io()->writeln("Hey-yo $name via StyleInterface!"); + return 0; + }); + + $e['commands'][] = (new BaseCommand('hello:noboot')) + ->setDescription('Say a greeting') + ->addArgument('name') + ->setBootOptions(['auto' => FALSE]) + ->setCode(function(InputInterface $input, OutputInterface $output) { + // ASSERT: With setCode(), it's OK to use hinted inputs. + if ($input->getArgument('name') !== Cv::input()->getArgument('name')) { + throw new \RuntimeException("Argument \"name\" is inconsistent!"); + } + if (class_exists('Civi\Core\Container')) { + throw new \LogicException("Container should not have been booted by BaseCommand!"); + } + $name = $input->getArgument('name') ?: 'world'; + $output->writeln("Hey-yo $name via parameter!"); + Cv::io()->writeln("Hey-yo $name via StyleInterface!"); + return 0; + }); + +}); From bbccfc4bd601507ef0c66e7caecd7f29f0247f8b Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 24 Sep 2024 11:31:36 -0700 Subject: [PATCH 18/90] (REF) Convert BaseExtensionCommand to BaseCommand+ExtensionTrait --- src/Command/ExtensionDisableCommand.php | 5 ++++- src/Command/ExtensionDownloadCommand.php | 7 +++++-- src/Command/ExtensionEnableCommand.php | 5 ++++- src/Command/ExtensionListCommand.php | 6 ++++-- src/Command/ExtensionUninstallCommand.php | 5 ++++- src/Command/ExtensionUpgradeDbCommand.php | 5 ++++- src/Command/HttpCommand.php | 4 +++- src/Command/PathCommand.php | 4 +++- src/Command/UrlCommand.php | 4 +++- .../BaseExtensionCommand.php => Util/ExtensionTrait.php} | 5 +++-- tests/Command/BaseExtensionCommandTest.php | 5 ++++- 11 files changed, 41 insertions(+), 14 deletions(-) rename src/{Command/BaseExtensionCommand.php => Util/ExtensionTrait.php} (96%) diff --git a/src/Command/ExtensionDisableCommand.php b/src/Command/ExtensionDisableCommand.php index fe3c2975..0e72889b 100644 --- a/src/Command/ExtensionDisableCommand.php +++ b/src/Command/ExtensionDisableCommand.php @@ -1,12 +1,15 @@ configureRepoOptions(); } protected function initialize(InputInterface $input, OutputInterface $output) { diff --git a/src/Command/ExtensionEnableCommand.php b/src/Command/ExtensionEnableCommand.php index 2ba86313..1cef2a6b 100644 --- a/src/Command/ExtensionEnableCommand.php +++ b/src/Command/ExtensionEnableCommand.php @@ -1,13 +1,16 @@ configureRepoOptions(); } protected function initialize(InputInterface $input, OutputInterface $output) { diff --git a/src/Command/ExtensionUninstallCommand.php b/src/Command/ExtensionUninstallCommand.php index 74e77852..b8aef554 100644 --- a/src/Command/ExtensionUninstallCommand.php +++ b/src/Command/ExtensionUninstallCommand.php @@ -1,12 +1,15 @@ $keys, 1=>$errors). */ diff --git a/tests/Command/BaseExtensionCommandTest.php b/tests/Command/BaseExtensionCommandTest.php index 83bc2bbc..dc28ff0e 100644 --- a/tests/Command/BaseExtensionCommandTest.php +++ b/tests/Command/BaseExtensionCommandTest.php @@ -1,6 +1,7 @@ configureRepoOptions(); $input = new ArgvInput($inputArgv, $c->getDefinition()); From 149bb4a2d45aa5a3974bddf0b62de166df8632df Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 24 Sep 2024 11:39:58 -0700 Subject: [PATCH 19/90] (REF) Rename BaseCommand to CvCommand It can be used in either inherited style or fluent style. And this symbol has not yet been published via cv-lib --- doc/plugins.md | 3 ++- lib/README.md | 2 +- lib/src/Command/{BaseCommand.php => CvCommand.php} | 4 ++-- src/Command/AngularHtmlListCommand.php | 2 +- src/Command/AngularHtmlShowCommand.php | 2 +- src/Command/AngularModuleListCommand.php | 2 +- src/Command/Api4Command.php | 2 +- src/Command/ApiBatchCommand.php | 2 +- src/Command/ApiCommand.php | 2 +- src/Command/BootCommand.php | 2 +- src/Command/CliCommand.php | 2 +- src/Command/CoreCheckReqCommand.php | 2 +- src/Command/CoreInstallCommand.php | 2 +- src/Command/CoreUninstallCommand.php | 2 +- src/Command/DebugContainerCommand.php | 2 +- src/Command/DebugDispatcherCommand.php | 2 +- src/Command/EditCommand.php | 2 +- src/Command/EvalCommand.php | 2 +- src/Command/ExtensionDisableCommand.php | 2 +- src/Command/ExtensionDownloadCommand.php | 2 +- src/Command/ExtensionEnableCommand.php | 2 +- src/Command/ExtensionListCommand.php | 2 +- src/Command/ExtensionUninstallCommand.php | 2 +- src/Command/ExtensionUpgradeDbCommand.php | 2 +- src/Command/FillCommand.php | 2 +- src/Command/FlushCommand.php | 2 +- src/Command/HttpCommand.php | 2 +- src/Command/PathCommand.php | 2 +- src/Command/PipeCommand.php | 2 +- src/Command/ScriptCommand.php | 2 +- src/Command/SettingGetCommand.php | 2 +- src/Command/SettingRevertCommand.php | 2 +- src/Command/SettingSetCommand.php | 2 +- src/Command/ShowCommand.php | 2 +- src/Command/SqlCliCommand.php | 2 +- src/Command/UpgradeCommand.php | 2 +- src/Command/UpgradeDbCommand.php | 2 +- src/Command/UpgradeDlCommand.php | 2 +- src/Command/UpgradeGetCommand.php | 2 +- src/Command/UpgradeReportCommand.php | 2 +- src/Command/UrlCommand.php | 2 +- tests/Command/BaseExtensionCommandTest.php | 2 +- tests/Plugin/FluentHelloPluginTest/hello.php | 10 +++++----- 43 files changed, 49 insertions(+), 48 deletions(-) rename lib/src/Command/{BaseCommand.php => CvCommand.php} (90%) diff --git a/doc/plugins.md b/doc/plugins.md index 144afd5d..541d091d 100644 --- a/doc/plugins.md +++ b/doc/plugins.md @@ -7,6 +7,7 @@ Cv plugins are PHP files which register event listeners. ```php // FILE: /etc/cv/plugin/hello-command.php use Civi\Cv\Cv; +use Civi\Cv\Command\CvCommand; use CvDeps\Symfony\Component\Console\Input\InputInterface; use CvDeps\Symfony\Component\Console\Input\InputArgument; use CvDeps\Symfony\Component\Console\Output\OutputInterface; @@ -18,7 +19,7 @@ if (empty($CV_PLUGIN['protocol']) || $CV_PLUGIN['protocol'] > 1) { Cv::dispatcher()->addListener('cv.app.commands', function($e) { - $e['commands'][] = (new \Civi\Cv\Command\BaseCommand('hello')) + $e['commands'][] = (new CvCommand('hello')) ->setDescription('Say a greeting') ->addArgument('name', InputArgument::REQUIRED, 'Name of the person to greet') ->setCode(function($input, $output) { diff --git a/lib/README.md b/lib/README.md index c45a3305..3ce8352f 100644 --- a/lib/README.md +++ b/lib/README.md @@ -86,5 +86,5 @@ Civi bootstrap behaviors. (circa v0.3.44). It changed significantly (circa v0.3.56), where `configureBootOptions()` was replaced by `$bootOptions`, `mergeDefaultBootDefinition()`, and `mergeBootDefinition()`. -* As an alternative, consider the classes `BaseApplication` and `BaseCommand` if you aim +* As an alternative, consider the classes `BaseApplication` and `CvCommand` if you aim to build a tool using Symfony Console and Cv Lib. diff --git a/lib/src/Command/BaseCommand.php b/lib/src/Command/CvCommand.php similarity index 90% rename from lib/src/Command/BaseCommand.php rename to lib/src/Command/CvCommand.php index e896cb81..d6bc5616 100644 --- a/lib/src/Command/BaseCommand.php +++ b/lib/src/Command/CvCommand.php @@ -8,14 +8,14 @@ use Symfony\Component\Console\Output\OutputInterface; /** - * Cv's `BaseCommand` is a Symfony `Command` with support for bootstrapping CiviCRM/CMS. + * `CvCommand` is a Symfony `Command` with support for bootstrapping CiviCRM/CMS. * * - From end-user POV, the command accepts options like --user, --level, --hostname. * - From dev POV, the command allows you to implement `execute()` method without needing to * explicitly boot Civi. * - From dev POV, you may fine-tune command by changing the $bootOptions / getBootOptions(). */ -class BaseCommand extends Command { +class CvCommand extends Command { use OptionCallbackTrait; use BootTrait; diff --git a/src/Command/AngularHtmlListCommand.php b/src/Command/AngularHtmlListCommand.php index 84657424..9ced3f51 100644 --- a/src/Command/AngularHtmlListCommand.php +++ b/src/Command/AngularHtmlListCommand.php @@ -6,7 +6,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class AngularHtmlListCommand extends BaseCommand { +class AngularHtmlListCommand extends CvCommand { use StructuredOutputTrait; diff --git a/src/Command/AngularHtmlShowCommand.php b/src/Command/AngularHtmlShowCommand.php index d3bd80d8..fed17693 100644 --- a/src/Command/AngularHtmlShowCommand.php +++ b/src/Command/AngularHtmlShowCommand.php @@ -7,7 +7,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class AngularHtmlShowCommand extends BaseCommand { +class AngularHtmlShowCommand extends CvCommand { /** * @param string|null $name diff --git a/src/Command/AngularModuleListCommand.php b/src/Command/AngularModuleListCommand.php index 56781bce..73dbe08c 100644 --- a/src/Command/AngularModuleListCommand.php +++ b/src/Command/AngularModuleListCommand.php @@ -6,7 +6,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class AngularModuleListCommand extends BaseCommand { +class AngularModuleListCommand extends CvCommand { use StructuredOutputTrait; diff --git a/src/Command/Api4Command.php b/src/Command/Api4Command.php index a5f045b8..40e0b37c 100644 --- a/src/Command/Api4Command.php +++ b/src/Command/Api4Command.php @@ -9,7 +9,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class Api4Command extends BaseCommand { +class Api4Command extends CvCommand { use StructuredOutputTrait; diff --git a/src/Command/ApiBatchCommand.php b/src/Command/ApiBatchCommand.php index 49bd8786..8c0bdc71 100644 --- a/src/Command/ApiBatchCommand.php +++ b/src/Command/ApiBatchCommand.php @@ -5,7 +5,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class ApiBatchCommand extends BaseCommand { +class ApiBatchCommand extends CvCommand { /** * @var array diff --git a/src/Command/ApiCommand.php b/src/Command/ApiCommand.php index 6a73dbcd..6713afb2 100644 --- a/src/Command/ApiCommand.php +++ b/src/Command/ApiCommand.php @@ -8,7 +8,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class ApiCommand extends BaseCommand { +class ApiCommand extends CvCommand { use StructuredOutputTrait; diff --git a/src/Command/BootCommand.php b/src/Command/BootCommand.php index c0350911..60dc8df1 100644 --- a/src/Command/BootCommand.php +++ b/src/Command/BootCommand.php @@ -4,7 +4,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class BootCommand extends BaseCommand { +class BootCommand extends CvCommand { protected function configure() { $this diff --git a/src/Command/CliCommand.php b/src/Command/CliCommand.php index eb3fbd7c..1d24a05d 100644 --- a/src/Command/CliCommand.php +++ b/src/Command/CliCommand.php @@ -9,7 +9,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class CliCommand extends BaseCommand { +class CliCommand extends CvCommand { protected function configure() { $this diff --git a/src/Command/CoreCheckReqCommand.php b/src/Command/CoreCheckReqCommand.php index 8a1cedc4..033db002 100644 --- a/src/Command/CoreCheckReqCommand.php +++ b/src/Command/CoreCheckReqCommand.php @@ -8,7 +8,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class CoreCheckReqCommand extends BaseCommand { +class CoreCheckReqCommand extends CvCommand { use SetupCommandTrait; use DebugDispatcherTrait; diff --git a/src/Command/CoreInstallCommand.php b/src/Command/CoreInstallCommand.php index 5649932d..96e6bc24 100644 --- a/src/Command/CoreInstallCommand.php +++ b/src/Command/CoreInstallCommand.php @@ -10,7 +10,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ChoiceQuestion; -class CoreInstallCommand extends BaseCommand { +class CoreInstallCommand extends CvCommand { use SetupCommandTrait; use DebugDispatcherTrait; diff --git a/src/Command/CoreUninstallCommand.php b/src/Command/CoreUninstallCommand.php index f8c4dbdd..858b23b7 100644 --- a/src/Command/CoreUninstallCommand.php +++ b/src/Command/CoreUninstallCommand.php @@ -10,7 +10,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ConfirmationQuestion; -class CoreUninstallCommand extends BaseCommand { +class CoreUninstallCommand extends CvCommand { use SetupCommandTrait; use DebugDispatcherTrait; diff --git a/src/Command/DebugContainerCommand.php b/src/Command/DebugContainerCommand.php index 6824751c..87673a99 100644 --- a/src/Command/DebugContainerCommand.php +++ b/src/Command/DebugContainerCommand.php @@ -11,7 +11,7 @@ use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; -class DebugContainerCommand extends BaseCommand { +class DebugContainerCommand extends CvCommand { use StructuredOutputTrait; diff --git a/src/Command/DebugDispatcherCommand.php b/src/Command/DebugDispatcherCommand.php index a7a2500d..4ca7f609 100644 --- a/src/Command/DebugDispatcherCommand.php +++ b/src/Command/DebugDispatcherCommand.php @@ -6,7 +6,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class DebugDispatcherCommand extends BaseCommand { +class DebugDispatcherCommand extends CvCommand { use DebugDispatcherTrait; diff --git a/src/Command/EditCommand.php b/src/Command/EditCommand.php index d1daa858..9b2375b9 100644 --- a/src/Command/EditCommand.php +++ b/src/Command/EditCommand.php @@ -12,7 +12,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class EditCommand extends BaseCommand { +class EditCommand extends CvCommand { /** * @var \Civi\Cv\Util\CliEditor diff --git a/src/Command/EvalCommand.php b/src/Command/EvalCommand.php index 2c5b960a..81bdb571 100644 --- a/src/Command/EvalCommand.php +++ b/src/Command/EvalCommand.php @@ -7,7 +7,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class EvalCommand extends BaseCommand { +class EvalCommand extends CvCommand { use StructuredOutputTrait; diff --git a/src/Command/ExtensionDisableCommand.php b/src/Command/ExtensionDisableCommand.php index 0e72889b..8f17c070 100644 --- a/src/Command/ExtensionDisableCommand.php +++ b/src/Command/ExtensionDisableCommand.php @@ -7,7 +7,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class ExtensionDisableCommand extends BaseCommand { +class ExtensionDisableCommand extends CvCommand { use ExtensionTrait; diff --git a/src/Command/ExtensionDownloadCommand.php b/src/Command/ExtensionDownloadCommand.php index 71068e79..105f0591 100644 --- a/src/Command/ExtensionDownloadCommand.php +++ b/src/Command/ExtensionDownloadCommand.php @@ -11,7 +11,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ChoiceQuestion; -class ExtensionDownloadCommand extends BaseCommand { +class ExtensionDownloadCommand extends CvCommand { use ExtensionTrait; diff --git a/src/Command/ExtensionEnableCommand.php b/src/Command/ExtensionEnableCommand.php index 1cef2a6b..73e618e6 100644 --- a/src/Command/ExtensionEnableCommand.php +++ b/src/Command/ExtensionEnableCommand.php @@ -8,7 +8,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class ExtensionEnableCommand extends BaseCommand { +class ExtensionEnableCommand extends CvCommand { use ExtensionTrait; diff --git a/src/Command/ExtensionListCommand.php b/src/Command/ExtensionListCommand.php index 8b65c38b..09e6a63d 100644 --- a/src/Command/ExtensionListCommand.php +++ b/src/Command/ExtensionListCommand.php @@ -12,7 +12,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class ExtensionListCommand extends BaseCommand { +class ExtensionListCommand extends CvCommand { use ExtensionTrait; use StructuredOutputTrait; diff --git a/src/Command/ExtensionUninstallCommand.php b/src/Command/ExtensionUninstallCommand.php index b8aef554..161db433 100644 --- a/src/Command/ExtensionUninstallCommand.php +++ b/src/Command/ExtensionUninstallCommand.php @@ -7,7 +7,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class ExtensionUninstallCommand extends BaseCommand { +class ExtensionUninstallCommand extends CvCommand { use ExtensionTrait; diff --git a/src/Command/ExtensionUpgradeDbCommand.php b/src/Command/ExtensionUpgradeDbCommand.php index 26386cdf..5fbbfd81 100644 --- a/src/Command/ExtensionUpgradeDbCommand.php +++ b/src/Command/ExtensionUpgradeDbCommand.php @@ -6,7 +6,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class ExtensionUpgradeDbCommand extends BaseCommand { +class ExtensionUpgradeDbCommand extends CvCommand { use ExtensionTrait; diff --git a/src/Command/FillCommand.php b/src/Command/FillCommand.php index 91eb22de..2199d163 100644 --- a/src/Command/FillCommand.php +++ b/src/Command/FillCommand.php @@ -8,7 +8,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class FillCommand extends BaseCommand { +class FillCommand extends CvCommand { protected $fields; diff --git a/src/Command/FlushCommand.php b/src/Command/FlushCommand.php index 6b3cab44..d38e1ed1 100644 --- a/src/Command/FlushCommand.php +++ b/src/Command/FlushCommand.php @@ -6,7 +6,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class FlushCommand extends BaseCommand { +class FlushCommand extends CvCommand { protected function configure() { $this diff --git a/src/Command/HttpCommand.php b/src/Command/HttpCommand.php index c53fc360..e7ccc5a5 100644 --- a/src/Command/HttpCommand.php +++ b/src/Command/HttpCommand.php @@ -10,7 +10,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class HttpCommand extends BaseCommand { +class HttpCommand extends CvCommand { use ExtensionTrait; use StructuredOutputTrait; diff --git a/src/Command/PathCommand.php b/src/Command/PathCommand.php index 773005df..1eef6dd9 100644 --- a/src/Command/PathCommand.php +++ b/src/Command/PathCommand.php @@ -7,7 +7,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class PathCommand extends BaseCommand { +class PathCommand extends CvCommand { use ExtensionTrait; use StructuredOutputTrait; diff --git a/src/Command/PipeCommand.php b/src/Command/PipeCommand.php index 7e2bb51d..6d64eb72 100644 --- a/src/Command/PipeCommand.php +++ b/src/Command/PipeCommand.php @@ -5,7 +5,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class PipeCommand extends BaseCommand { +class PipeCommand extends CvCommand { /** * @param string|null $name diff --git a/src/Command/ScriptCommand.php b/src/Command/ScriptCommand.php index 41364e0f..df20c771 100644 --- a/src/Command/ScriptCommand.php +++ b/src/Command/ScriptCommand.php @@ -6,7 +6,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class ScriptCommand extends BaseCommand { +class ScriptCommand extends CvCommand { protected function configure() { $this diff --git a/src/Command/SettingGetCommand.php b/src/Command/SettingGetCommand.php index 9af13a4a..cf7e0458 100644 --- a/src/Command/SettingGetCommand.php +++ b/src/Command/SettingGetCommand.php @@ -8,7 +8,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class SettingGetCommand extends BaseCommand { +class SettingGetCommand extends CvCommand { use StructuredOutputTrait; use SettingTrait; diff --git a/src/Command/SettingRevertCommand.php b/src/Command/SettingRevertCommand.php index c1ba4766..722210c9 100644 --- a/src/Command/SettingRevertCommand.php +++ b/src/Command/SettingRevertCommand.php @@ -8,7 +8,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class SettingRevertCommand extends BaseCommand { +class SettingRevertCommand extends CvCommand { use StructuredOutputTrait; use SettingTrait; diff --git a/src/Command/SettingSetCommand.php b/src/Command/SettingSetCommand.php index 14f15f5f..cd717a0c 100644 --- a/src/Command/SettingSetCommand.php +++ b/src/Command/SettingSetCommand.php @@ -8,7 +8,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class SettingSetCommand extends BaseCommand { +class SettingSetCommand extends CvCommand { use StructuredOutputTrait; use SettingTrait; diff --git a/src/Command/ShowCommand.php b/src/Command/ShowCommand.php index f33b3401..2f5eea5f 100644 --- a/src/Command/ShowCommand.php +++ b/src/Command/ShowCommand.php @@ -6,7 +6,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class ShowCommand extends BaseCommand { +class ShowCommand extends CvCommand { use StructuredOutputTrait; diff --git a/src/Command/SqlCliCommand.php b/src/Command/SqlCliCommand.php index 65b29933..367af698 100644 --- a/src/Command/SqlCliCommand.php +++ b/src/Command/SqlCliCommand.php @@ -7,7 +7,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class SqlCliCommand extends BaseCommand { +class SqlCliCommand extends CvCommand { protected function configure() { $this diff --git a/src/Command/UpgradeCommand.php b/src/Command/UpgradeCommand.php index cbf3e877..ca5b87a9 100644 --- a/src/Command/UpgradeCommand.php +++ b/src/Command/UpgradeCommand.php @@ -6,7 +6,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class UpgradeCommand extends BaseCommand { +class UpgradeCommand extends CvCommand { use StructuredOutputTrait; diff --git a/src/Command/UpgradeDbCommand.php b/src/Command/UpgradeDbCommand.php index 37c64899..87be5804 100644 --- a/src/Command/UpgradeDbCommand.php +++ b/src/Command/UpgradeDbCommand.php @@ -10,7 +10,7 @@ /** * Command for asking CiviCRM for the appropriate tarball to download. */ -class UpgradeDbCommand extends BaseCommand { +class UpgradeDbCommand extends CvCommand { use StructuredOutputTrait; diff --git a/src/Command/UpgradeDlCommand.php b/src/Command/UpgradeDlCommand.php index 7b1c7e62..d9482348 100644 --- a/src/Command/UpgradeDlCommand.php +++ b/src/Command/UpgradeDlCommand.php @@ -11,7 +11,7 @@ /** * Command for asking CiviCRM for the appropriate tarball to download. */ -class UpgradeDlCommand extends BaseCommand { +class UpgradeDlCommand extends CvCommand { use StructuredOutputTrait; diff --git a/src/Command/UpgradeGetCommand.php b/src/Command/UpgradeGetCommand.php index 6d2da3bc..5f4ff0ee 100644 --- a/src/Command/UpgradeGetCommand.php +++ b/src/Command/UpgradeGetCommand.php @@ -9,7 +9,7 @@ /** * Command for asking CiviCRM for the appropriate tarball to download. */ -class UpgradeGetCommand extends BaseCommand { +class UpgradeGetCommand extends CvCommand { const DEFAULT_CHECK_URL = "https://upgrade.civicrm.org/check"; // const DEFAULT_CHECK_URL = "http://civicrm-upgrade-manager.l/check"; diff --git a/src/Command/UpgradeReportCommand.php b/src/Command/UpgradeReportCommand.php index eaf9b367..366fcc2a 100644 --- a/src/Command/UpgradeReportCommand.php +++ b/src/Command/UpgradeReportCommand.php @@ -9,7 +9,7 @@ /** * Command for asking CiviCRM for the appropriate tarball to download. */ -class UpgradeReportCommand extends BaseCommand { +class UpgradeReportCommand extends CvCommand { const DEFAULT_REPORT_URL = 'https://upgrade.civicrm.org/report'; use StructuredOutputTrait; diff --git a/src/Command/UrlCommand.php b/src/Command/UrlCommand.php index 9ca5e336..a5a74936 100644 --- a/src/Command/UrlCommand.php +++ b/src/Command/UrlCommand.php @@ -10,7 +10,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class UrlCommand extends BaseCommand { +class UrlCommand extends CvCommand { use ExtensionTrait; use StructuredOutputTrait; diff --git a/tests/Command/BaseExtensionCommandTest.php b/tests/Command/BaseExtensionCommandTest.php index dc28ff0e..26f4a81a 100644 --- a/tests/Command/BaseExtensionCommandTest.php +++ b/tests/Command/BaseExtensionCommandTest.php @@ -37,7 +37,7 @@ public function repoOptionExamples() { * @dataProvider repoOptionExamples */ public function testParseRepo($inputArgv, $expectUrl) { - $c = new class('ext:example') extends BaseCommand { + $c = new class('ext:example') extends CvCommand { use ExtensionTrait; }; $c->configureRepoOptions(); diff --git a/tests/Plugin/FluentHelloPluginTest/hello.php b/tests/Plugin/FluentHelloPluginTest/hello.php index 8c5a36b7..b4bef8bf 100644 --- a/tests/Plugin/FluentHelloPluginTest/hello.php +++ b/tests/Plugin/FluentHelloPluginTest/hello.php @@ -1,7 +1,7 @@ addListener('cv.app.commands', function ($e) { - $e['commands'][] = (new BaseCommand('hello:normal')) + $e['commands'][] = (new CvCommand('hello:normal')) ->setDescription('Say a greeting') ->addArgument('name') ->setCode(function($input, $output) { @@ -39,7 +39,7 @@ throw new \RuntimeException("Argument \"name\" is inconsistent!"); } if (!Civi\Core\Container::isContainerBooted()) { - throw new \LogicException("Container should have been booted by BaseCommand!"); + throw new \LogicException("Container should have been booted by CvCommand!"); } $name = $input->getArgument('name') ?: 'world'; $output->writeln("Hey-yo $name via parameter!"); @@ -47,7 +47,7 @@ return 0; }); - $e['commands'][] = (new BaseCommand('hello:noboot')) + $e['commands'][] = (new CvCommand('hello:noboot')) ->setDescription('Say a greeting') ->addArgument('name') ->setBootOptions(['auto' => FALSE]) @@ -57,7 +57,7 @@ throw new \RuntimeException("Argument \"name\" is inconsistent!"); } if (class_exists('Civi\Core\Container')) { - throw new \LogicException("Container should not have been booted by BaseCommand!"); + throw new \LogicException("Container should not have been booted by CvCommand!"); } $name = $input->getArgument('name') ?: 'world'; $output->writeln("Hey-yo $name via parameter!"); From 7738024c887f502b5e39822d2dcfec3a7e322499 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Thu, 26 Sep 2024 10:09:40 -0700 Subject: [PATCH 20/90] Update plugins.md --- doc/plugins.md | 88 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 74 insertions(+), 14 deletions(-) diff --git a/doc/plugins.md b/doc/plugins.md index 541d091d..6bec5bc6 100644 --- a/doc/plugins.md +++ b/doc/plugins.md @@ -2,16 +2,16 @@ Cv plugins are PHP files which register event listeners. -## Example: Add command +## Quick Start + +Let's create a global plugin. Add the file `/etc/cv/plugin/hello-command.php` with this content: ```php -// FILE: /etc/cv/plugin/hello-command.php use Civi\Cv\Cv; use Civi\Cv\Command\CvCommand; use CvDeps\Symfony\Component\Console\Input\InputInterface; use CvDeps\Symfony\Component\Console\Input\InputArgument; use CvDeps\Symfony\Component\Console\Output\OutputInterface; -use CvDeps\Symfony\Component\Console\Command\Command; if (empty($CV_PLUGIN['protocol']) || $CV_PLUGIN['protocol'] > 1) { die(__FILE__ . " has only been tested with CV_PLUGIN API v1"); @@ -22,7 +22,7 @@ Cv::dispatcher()->addListener('cv.app.commands', function($e) { $e['commands'][] = (new CvCommand('hello')) ->setDescription('Say a greeting') ->addArgument('name', InputArgument::REQUIRED, 'Name of the person to greet') - ->setCode(function($input, $output) { + ->setCode(function(InputInterface $input, OutputInterface $output) { $output->writeln('Hello, ' . $input->getArgument('name')); return 0; }); @@ -30,6 +30,16 @@ Cv::dispatcher()->addListener('cv.app.commands', function($e) { }); ``` +Key points: + +* You can create a global plugin by putting a PHP file in a suitable folder. +* The important namespaces are `\Civi\Cv` (*classes and helpers from `cv`*) and `\CvDeps` (*third-party dependencies bundled with `cv`*). +* If there is a major change in how plugins are loaded, you will get an error notice. +* To develop functionality, we use helpers like `Cv::io()` and events like `cv.app.*`. +* Specifically, to register a command, one uses `cv.app.command` and makes an instance of `CvCommand`. + +Each of these topics is explored more in the subsequent sections. + ## Plugin loading *Global plugins* are loaded from the `CV_PLUGIN_PATH`. All `*.php` files in @@ -61,6 +71,23 @@ Plugins execute within `cv`'s process, so they are affected by `cv`'s namespace- * Internal dependencies (eg Symfony Console) are bundled with `cv`. They are generally prefixed, though the concrete names vary. To maximize portability, access these classes with the logical alias `CvDeps\*` (eg `CvDeps\Symfony\Component\Console\*`). +## `Cv` helpers + +The `\Civi\Cv\Cv` facade provides some helpers for implementing functionality: + +* Input/Output + * __`Cv::io()`__: Get the Symfony "Style" interface for current subcommand. (*This provides high-level functions for interaction with the console user.*) + * __`Cv::input()`__: Get the Symfony "Input" interface for current subcommand. (*This is a mid-level helper for examining CLI parameters/arguments.*) + * __`Cv::output()`__: Get the Symfony "Output" interface for current subcommand. (*This is a mid-level helper for basic formatting of the output.*) + * (*During cv's initial bootstrap, there is no active subcommand. These may return stubs.*) +* Event + * __`Cv::dispatcher()`__: Get a reference to the dispatcher service. Add listeners and/or fire events. + * __`Cv::filter(string $eventName, array $eventData)`__: Fire a basic event to modify `$eventData`. +* Reflection + * __`Cv::app()`__: Get a reference to the active/top-level `cv` command. + * __`Cv::plugins()`__: Get a reference to the plugin subsystem. + * __`Cv::ioStack()`__: Manage active instances of the input/output services. + ## Events * `cv.app.boot`: Fires immediately when the application starts @@ -80,18 +107,51 @@ Plugins execute within `cv`'s process, so they are affected by `cv`'s namespace- (Note: When subscribing to an event like `cv.app.site-alias`, you may alternatively subscribe to the wildcard `*.app.site-alias`. In the future, this should allow you hook into adjacent commands like civix and coworker.) -## `Cv` helpers +## Commands -The `\Civi\Cv\Cv` facade provides some helpers for implementing functionality: +You can register new subcommands within `cv`. `cv` is built around [Symfony Console 5.x](https://symfony.com/doc/5.x/components/console.html), so commands must extend a suitable base-class, such as: -* Event helpers - * __`Cv::dispatcher()`__: Get a reference to the dispatcher service. Add listeners and/or fire events. - * __`Cv::filter(string $eventName, array $eventData)`__: Fire a basic event to modify `$eventData`. -* I/O helpers - * __`Cv::io()`__: Get the Symfony "Style" interface for current subcommand - * __`Cv::input()`__: Get the Symfony "Input" interface for current subcommand - * __`Cv::output()`__: Get the Symfony "Output" interface for current subcommand - * (*During cv's initial bootstrap, there is no active subcommand. These return stubs.*) +* `CvDeps\Symfony\Component\Console\Command\Command` is the original building-block from Symfony Console. This may be suitable for some basic commands. This is largely documented by upstream. +* `Civi\Cv\Command\CvCommand` (v0.3.56+) is an extended version. It can automatically boot CiviCRM and CMS. It handles common options like `--user`, `--hostname`, and `--level`, and it respect environment-variables like `CIVICRM_BOOT`. + +For this document, we focus on `CvCommand` because it's more appropriate for `cv` subcommands. + +Subcommands can be written in a few coding-styles, such as the *fluent-object* style or a *structured-class* style. Compare: + +```php +## Fluent-object style of command +$command = (new CvCommand('my-command')) + ->setDescription('Say a greeting') + ->addArgument('name', InputArgument::REQUIRED, 'Name of the person to greet') + ->setCode(function($input, $output) { + $output->writeln('Hello, ' . $input->getArgument('name')); + return 0; + }); +``` + +```php +## Structured-class style +class MyCommand extends CvCommand { + + public function configure() { + $this->setName('my-command'); + $this->setDescription('Say a greeting'); + $this->addArgument('name', InputArgument::REQUIRED, 'Name of the person to greet'); + } + + public functione execute(InputInterface $input, OutputInterface $output): int { + $output->writeln('Hello, ' . $input->getArgument('name')); + return 0; + } +} + +$command = new MyCommand(); +``` + +Both styles can be used in any kind of `cv` plugin, but you may find some affinities: + +* The fluent-object style is shorter, and it doesn't require class-loading or subfolders. If your aim is to deliver the plugin as one `*.php` file, then this style may fit better. +* The structured-class style is more verbose and more organized (classes, namespaces, subfolders). If you implement several commands, this can keep things tidy. But it requires some glue (such as class-loading). If your aim is to bundle commands into CiviCRM extension, then this style may fit better. ## `$CV_PLUGIN` data From c7d02c3ffe9c8177f6654edef226718acbaf2d09 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Thu, 26 Sep 2024 10:19:42 -0700 Subject: [PATCH 21/90] Update plugins.md --- doc/plugins.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/doc/plugins.md b/doc/plugins.md index 6bec5bc6..09047569 100644 --- a/doc/plugins.md +++ b/doc/plugins.md @@ -34,6 +34,7 @@ Key points: * You can create a global plugin by putting a PHP file in a suitable folder. * The important namespaces are `\Civi\Cv` (*classes and helpers from `cv`*) and `\CvDeps` (*third-party dependencies bundled with `cv`*). +* `cv` is built with the [Symfony Console (5.x) library](https://symfony.com/doc/5.x/components/console.html). * If there is a major change in how plugins are loaded, you will get an error notice. * To develop functionality, we use helpers like `Cv::io()` and events like `cv.app.*`. * Specifically, to register a command, one uses `cv.app.command` and makes an instance of `CvCommand`. @@ -109,12 +110,12 @@ The `\Civi\Cv\Cv` facade provides some helpers for implementing functionality: ## Commands -You can register new subcommands within `cv`. `cv` is built around [Symfony Console 5.x](https://symfony.com/doc/5.x/components/console.html), so commands must extend a suitable base-class, such as: +You can register new subcommands within `cv`. `cv` includes the base-class from Symfony Console, and its adds another base-class. Compare: -* `CvDeps\Symfony\Component\Console\Command\Command` is the original building-block from Symfony Console. This may be suitable for some basic commands. This is largely documented by upstream. -* `Civi\Cv\Command\CvCommand` (v0.3.56+) is an extended version. It can automatically boot CiviCRM and CMS. It handles common options like `--user`, `--hostname`, and `--level`, and it respect environment-variables like `CIVICRM_BOOT`. +* `CvDeps\Symfony\Component\Console\Command\Command` is the original building-block from Symfony Console. It can define and parse CLI arguments, but it does *not* bootstrap CiviCRM or CMS. It may be suitable for some basic commands. Documentation is provided by upstream. +* `Civi\Cv\Command\CvCommand` (v0.3.56+) is an extended version. It automatically boots CiviCRM and CMS. It handles common options like `--user`, `--hostname`, and `--level`, and it respect environment-variables like `CIVICRM_BOOT`. -For this document, we focus on `CvCommand` because it's more appropriate for `cv` subcommands. +For this document, we focus on `CvCommand`. Subcommands can be written in a few coding-styles, such as the *fluent-object* style or a *structured-class* style. Compare: From 089847f102c29b3e1050ff8478aa9eb35088e9d6 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Sat, 28 Sep 2024 14:14:34 -0700 Subject: [PATCH 22/90] cv flush - Remove extra call to boot() As part of the reorg to use CvCommand, most of the explicit calls to `boot()` were dropped (ae1ed440417b93076aa1e34b7fdc1f4978b33188) in favor of the inherited/automatic boot. This one fell through the cracks. The old call to `$this->boot()` probably should've been dropped at the same time (or else FlushCommand should've been flagged as manual-boot). In any event, this call is duplicative -- and in some configurations, it leads to a chain of other errors (#220). --- src/Command/FlushCommand.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Command/FlushCommand.php b/src/Command/FlushCommand.php index d38e1ed1..63895bc9 100644 --- a/src/Command/FlushCommand.php +++ b/src/Command/FlushCommand.php @@ -24,12 +24,11 @@ protected function initialize(InputInterface $input, OutputInterface $output) { // stale class-references that might be retained by the container cache. define('CIVICRM_CONTAINER_CACHE', 'never'); + // Now we can let the parent proceed with bootstrap... parent::initialize($input, $output); } protected function execute(InputInterface $input, OutputInterface $output): int { - $this->boot($input, $output); - $params = array(); if ($input->getOption('triggers')) { $params['triggers'] = TRUE; From 7e7b22bf3c02238dcdbe0a4957d0226e7f5c3d4a Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Sat, 28 Sep 2024 14:48:22 -0700 Subject: [PATCH 23/90] (NFC) doc/plugins.md --- doc/plugins.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/plugins.md b/doc/plugins.md index 09047569..4f1ce90e 100644 --- a/doc/plugins.md +++ b/doc/plugins.md @@ -84,7 +84,7 @@ The `\Civi\Cv\Cv` facade provides some helpers for implementing functionality: * Event * __`Cv::dispatcher()`__: Get a reference to the dispatcher service. Add listeners and/or fire events. * __`Cv::filter(string $eventName, array $eventData)`__: Fire a basic event to modify `$eventData`. -* Reflection +* Reflection * __`Cv::app()`__: Get a reference to the active/top-level `cv` command. * __`Cv::plugins()`__: Get a reference to the plugin subsystem. * __`Cv::ioStack()`__: Manage active instances of the input/output services. @@ -104,7 +104,7 @@ The `\Civi\Cv\Cv` facade provides some helpers for implementing functionality: * __Argument__: `$e['output']`: Reference to the `OutputInterface` * __Argument__: `$e['argv']`: Raw/original arguments passed to the current command * __Argument__: `$e['transport']`: Alternable callback (output). Fill in a value to specify how to forward the command to the referenced site. - * __Argument__: `$e['exec']`: Non-alterable callback (input). Use this if you need to immediately call the action within the current process. + * __Argument__: `$e['exec']`: Non-alterable callback (input). Use this if you need to immediately call the action within the current process. (Note: When subscribing to an event like `cv.app.site-alias`, you may alternatively subscribe to the wildcard `*.app.site-alias`. In the future, this should allow you hook into adjacent commands like civix and coworker.) @@ -112,7 +112,7 @@ The `\Civi\Cv\Cv` facade provides some helpers for implementing functionality: You can register new subcommands within `cv`. `cv` includes the base-class from Symfony Console, and its adds another base-class. Compare: -* `CvDeps\Symfony\Component\Console\Command\Command` is the original building-block from Symfony Console. It can define and parse CLI arguments, but it does *not* bootstrap CiviCRM or CMS. It may be suitable for some basic commands. Documentation is provided by upstream. +* `CvDeps\Symfony\Component\Console\Command\Command` is the original building-block from Symfony Console. It can define and parse CLI arguments, but it does *not* bootstrap CiviCRM or CMS. It may be suitable for some basic commands. Documentation is provided by upstream. * `Civi\Cv\Command\CvCommand` (v0.3.56+) is an extended version. It automatically boots CiviCRM and CMS. It handles common options like `--user`, `--hostname`, and `--level`, and it respect environment-variables like `CIVICRM_BOOT`. For this document, we focus on `CvCommand`. From c8d80a5a0a27d07a1663f07ab7f997297f3ce531 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Sat, 28 Sep 2024 15:23:21 -0700 Subject: [PATCH 24/90] cv sql - Fix recent regression. Add sniff test. --- src/Command/SqlCliCommand.php | 1 + tests/Command/SqlCliCommandTest.php | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 tests/Command/SqlCliCommandTest.php diff --git a/src/Command/SqlCliCommand.php b/src/Command/SqlCliCommand.php index 367af698..added482 100644 --- a/src/Command/SqlCliCommand.php +++ b/src/Command/SqlCliCommand.php @@ -41,6 +41,7 @@ protected function initialize(InputInterface $input, OutputInterface $output) { if ($input->getOption('dry-run') && $output->getVerbosity() <= OutputInterface::VERBOSITY_NORMAL) { $output->setVerbosity(OutputInterface::VERBOSITY_VERBOSE); } + parent::initialize($input, $output); } protected function execute(InputInterface $input, OutputInterface $output): int { diff --git a/tests/Command/SqlCliCommandTest.php b/tests/Command/SqlCliCommandTest.php new file mode 100644 index 00000000..aef4ecda --- /dev/null +++ b/tests/Command/SqlCliCommandTest.php @@ -0,0 +1,21 @@ +cv("sql")->setInput($query)); + $this->assertMatchesRegularExpression('/id\s+display_name/', $p->getOutput()); + } + +} From 4eb9d367b913ee10debfcf2f142ff285a168dd87 Mon Sep 17 00:00:00 2001 From: ufundo Date: Fri, 4 Oct 2024 14:44:22 +0100 Subject: [PATCH 25/90] dev/core#5392 set timezone for MySQL and PHP together --- lib/src/Util/BootTrait.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/src/Util/BootTrait.php b/lib/src/Util/BootTrait.php index 2db48498..6de9f647 100644 --- a/lib/src/Util/BootTrait.php +++ b/lib/src/Util/BootTrait.php @@ -200,7 +200,15 @@ public function _boot_full(InputInterface $input, OutputInterface $output) { } } - if (is_callable([\CRM_Core_Config::singleton()->userSystem, 'setMySQLTimeZone'])) { + // setTimeZone is preferred to ensure PHP and MySQL timezones are in sync on 5.80+ + // setMySQLTimeZone is retained for pre-existing behaviour on earlier versions + // @see https://github.com/civicrm/civicrm-core/pull/31225 + if (is_callable([\CRM_Core_Config::singleton()->userSystem, 'setTimeZone'])) { + $logger->debug('Set active timezone in MySQL / PHP'); + + \CRM_Core_Config::singleton()->userSystem->setTimeZone(); + } + elseif (is_callable([\CRM_Core_Config::singleton()->userSystem, 'setMySQLTimeZone'])) { $logger->debug('Set active MySQL timezone'); \CRM_Core_Config::singleton()->userSystem->setMySQLTimeZone(); From d3a7e5ab596da3ff6a60df490d95edd124d6c918 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 9 Oct 2024 15:43:02 -0700 Subject: [PATCH 26/90] CmsBootstrap - Fix excessively modest error-reporting Overview -------- When you call out the CMS to bootstrap the environment (CMS-first protocol), then it may sometimes adjust the PHP `error_reporting()` policy (eg to hide warnings from anonymous users). It's well and good for CMS to dictate that policy on web UI. But for CLI, we often prefer to communicate more to the console user. With this, the effective value of `error_reporting()` should be more consistent between traditional `Bootstrap.php` (Civi-first) and `CmsBootstrap.php` (CMS-first). Before ------ Once `CmsBootstrap` has started the CMS, it tries to reset the value. But the specific value used here is a poor choice. (I suspect there was an inversion error in the original draft -- `E_ALL` vs `1`. Either value is suboptimal, but `1` is particularly bad.) After ----- Once `CmsBootstrap` has started the CMS, it restores the prior value. This means that we preserve whatever value was defined by `php.ini` or `bin/cv` or `php -d error_reporting=` or similar. --- lib/src/CmsBootstrap.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/CmsBootstrap.php b/lib/src/CmsBootstrap.php index 2265b2f7..85cf7b57 100644 --- a/lib/src/CmsBootstrap.php +++ b/lib/src/CmsBootstrap.php @@ -163,6 +163,8 @@ public function bootCms() { $this->log->debug("Simulate web environment in CLI"); $this->simulateWebEnv($this->options['httpHost'], $cms['path'] . '/index.php'); } + + $originalErrorReporting = error_reporting(); $func = 'boot' . $cms['type']; if (!is_callable([$this, $func])) { throw new \Exception("Failed to locate boot function ($func)"); @@ -172,7 +174,7 @@ public function bootCms() { $cms['path'], $this->options['user'], $this->options['httpHost']); if (PHP_SAPI === "cli") { - error_reporting(1); + error_reporting($originalErrorReporting); } $this->log->debug("Finished"); From e45d36ca2fd07cceefa28deeeb4ae18dce4f5d35 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Sat, 2 Nov 2024 21:44:52 -0700 Subject: [PATCH 27/90] Implement an encoder for generating `cv api4` args --- src/Util/Api4ArgEncoder.php | 124 +++++++++++++++++++++++++++++ tests/Util/Api4ArgEncoderTest.php | 126 ++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 src/Util/Api4ArgEncoder.php create mode 100644 tests/Util/Api4ArgEncoderTest.php diff --git a/src/Util/Api4ArgEncoder.php b/src/Util/Api4ArgEncoder.php new file mode 100644 index 00000000..f6d8a4d1 --- /dev/null +++ b/src/Util/Api4ArgEncoder.php @@ -0,0 +1,124 @@ +encodeParams($params); + $suffix = empty($parts) ? '' : ' ' . implode(" ", array_map([$this, 'escapeShellArg'], $parts)); + return 'cv api4 ' . $this->escapeShellArg("$entity.$action") . $suffix; + } + + /** + * @internal + * @param array $params + * Ex: ['select' => ['foo', 'bar']] + * @return array + * List of distinct CLI arguments + * Ex: ['+s', 'foo,bar'] + */ + public function encodeParams(array $params = []): array { + $parts = []; + $oddballs = []; + $limitSet = FALSE; + foreach ($params as $key => $value) { + + switch (TRUE) { + case ($key === 'select'): + array_push($parts, '+s', implode(',', $value)); + break; + + case ($key === 'where' && !array_intersect(array_column($value, 0), ['AND', 'OR', 'NOT'])): + $symbolOperators = ['=', '<=' , '<', '>=', '>']; + foreach ($value as $clause) { + if (count($clause) < 3) { + $expr = $this->render($clause[0], 'sj') . ' ' . $clause[1]; + } + else { + $pad = (in_array($clause[1], $symbolOperators) && mb_strlen($clause[2]) < 10) ? '' : ' '; + $expr = $this->render($clause[0], 'sj') . $pad . $clause[1] . $pad . $this->render($clause[2], 'bnwj'); + } + array_push($parts, '+w', $expr); + } + break; + + case ($key === 'orderBy'): + foreach ($value as $field => $dir) { + array_push($parts, '+o', ($dir === 'ASC') ? "$field" : "$field $dir"); + } + break; + + case ($key === 'values'): + foreach ($value as $field => $val) { + array_push($parts, '+v', $field . '=' . $this->render($val, 'bnsj')); + } + break; + + case ($key === 'limit' || $key === 'offset'): + if (!$limitSet) { + $limitSet = TRUE; + $suffix = isset($params['offset']) ? '@' . $params['offset'] : ''; + array_push($parts, '+l', ($params['limit'] ?? '0') . $suffix); + } + break; + + default: + // If we can get away with "key=value", use that + if (preg_match('/^[a-zA-Z0-9_:]+$/', $key)) { + array_push($parts, $key . '=' . $this->render($value, 'bnsj')); + } + // Otherwise, fallback to straight JSON + else { + $oddballs[$key] = $value; + } + } + } + if (!empty($oddballs)) { + $parts[] = json_encode($oddballs); + } + return $parts; + } + + /** + * @param mixed $value + * @param string $rules + * In most contexts, you can use JSON. But some also allow bare numbers, bare words, or some + * standard strings. Give a list of rendering rules that are valid in this context. + * + * 'b': Boolean (TRUE<=>1; FALSE<=>0) + * 'w': Word (Bare word -- alphanumerics without spaces) + * 'n': Number (Bare number -- numerical digits with optional decimal) + * 's': Standard string (Alphanumerics and some limited punctuation that doesn't interfere with JSON, etc) + * 'j': JSON + * @return scalar + */ + private function render($value, string $rules) { + foreach (str_split($rules) as $rule) { + if ($rule === 'b' && ($value === TRUE || $value === FALSE)) { + return (int) $value; + } + elseif ($rule === 'n' && is_numeric($value)) { + return $value; + } + elseif ($rule === 'w' && is_string($value) && preg_match('/^[a-zA-Z0-9_.]*$/', $value)) { + return $value; + } + elseif ($rule === 's' && is_string($value) && preg_match('/^[a-zA-Z0-9_. :\/+-]*$/', $value)) { + // NOTE: Strings that start with `"` should be encoded through JSON. + return $value; + } + elseif ($rule === 'j') { + return json_encode($value); + } + } + } + + private function escapeShellArg(string $str): string { + $shellMetaRegex = '/[' . preg_quote(' {}\'\"\\<>[]&;', '/') . ']/'; + if (!preg_match($shellMetaRegex, $str)) { + return $str; + } + return escapeshellarg($str); + } + +} diff --git a/tests/Util/Api4ArgEncoderTest.php b/tests/Util/Api4ArgEncoderTest.php new file mode 100644 index 00000000..0051e927 --- /dev/null +++ b/tests/Util/Api4ArgEncoderTest.php @@ -0,0 +1,126 @@ + 10, 'select' => ['display_name']], + ['+l', '10', '+s', 'display_name'], + ]; + $exs[] = [ + ['limit' => 100], + ['+l', '100'], + ]; + $exs[] = [ + ['limit' => 15, 'offset' => 90], + ['+l', '15@90'], + ]; + $exs[] = [ + ['limit' => 10, 'select' => ['display_name', 'contact_type']], + ['+l', '10', '+s', 'display_name,contact_type'], + ]; + $exs[] = [ + [ + 'limit' => 10, + 'select' => ['display_name', 'contact_type'], + 'where' => [['id', '>', '123']], + ], + ['+l', '10', '+s', 'display_name,contact_type', '+w', 'id>123'], + ]; + $exs[] = [ + // Where conditions with various operators + ['where' => [['apple', 'IS NOT NULL'], ['banana', '>=', '234'], ['cherry', 'IS EMPTY'], ['date', 'IS NOT EMPTY']]], + ['+w', 'apple IS NOT NULL', '+w', 'banana>=234', '+w', 'cherry IS EMPTY', '+w', 'date IS NOT EMPTY'], + ]; + $exs[] = [ + // Where-conditions with synthetic fields + ['where' => [['foo:bar', '=', 'apple'], ['whiz.bang', '=', 'banana']]], + ['+w', 'foo:bar=apple', '+w', 'whiz.bang=banana'], + ]; + $exs[] = [ + // Value-assignments with synthetic fields + ['values' => ['foo:bar' => 'apple', 'whiz.bang' => 'banana']], + ['+v', 'foo:bar=apple', '+v', 'whiz.bang=banana'], + ]; + $exs[] = [ + [ + 'where' => [ + ['contact_type', 'NOT LIKE', 'Individual'], + ['display_name', 'LIKE', 'alice%'], + ['id', '>=', 234], + ], + ], + ['+w', 'contact_type NOT LIKE Individual', '+w', 'display_name LIKE "alice%"', '+w', 'id>=234'], + ]; + + $exs[] = [ + ['orderBy' => ['last_name' => 'ASC']], + ['+o', 'last_name'], + ]; + $exs[] = [ + // Multi-key order-by + ['orderBy' => ['last_name' => 'ASC', 'first_name' => 'DESC']], + ['+o', 'last_name', '+o', 'first_name DESC'], + ]; + $exs[] = [ + // Top-level param with object data + ['colors' => ['red' => '#f00', 'green' => '#0f0'], 'prefs' => ['foo' => TRUE, 'bar' => FALSE, 'whiz' => 0]], + ['colors={"red":"#f00","green":"#0f0"}', 'prefs={"foo":true,"bar":false,"whiz":0}'], + ]; + $exs[] = [ + // Mix of top-level params and selects + ['version' => 4, 'select' => ['foo']], + ['version=4', '+s', 'foo'], + ]; + $exs[] = [ + // Value-assignments with empty things + ['values' => ['blank' => '', 'zero' => '0']], + ['+v', 'blank=', '+v', 'zero=0'], + ]; + $exs[] = [ + // Value-assignments with various strings + ['values' => ['truth' => 'true', 'fallacy' => 'false', 'code' => '{foo}', 'literal_single_quotes' => "ab'cd", 'literal_double_quotes' => 'ab"cd', 'expr' => 'No true Scotsman']], + ['+v', 'truth=true', '+v', 'fallacy=false', '+v', 'code="{foo}"', '+v', 'literal_single_quotes="ab\'cd"', '+v', 'literal_double_quotes="ab\\"cd"', '+v', 'expr=No true Scotsman'], + ]; + $exs[] = [ + // Top-level params with booleans + ['checkPermissions' => TRUE, 'ignorePermissions' => FALSE], + ['checkPermissions=1', 'ignorePermissions=0'], + ]; + $exs[] = [ + // Value-assignments with booleans and numbers + ['values' => ['is_active' => TRUE, 'is_inactive' => FALSE, 'my_int' => 123, 'my_float' => 1.23]], + ['+v', 'is_active=1', '+v', 'is_inactive=0', '+v', 'my_int=123', '+v', 'my_float=1.23'], + ]; + $exs[] = [ + // Bespoke parameter with funny key-name + ['{foo}' => 123], + ['{"{foo}":123}'], + ]; + return $exs; + } + + /** + * @param $input + * @param $expected + * @dataProvider getGoodExamples + */ + public function testGoodInput($input, $expected) { + $encoder = new Api4ArgEncoder(); + $actual = $encoder->encodeParams($input); + // print_r(['expected' => $expected, 'actual' => $actual]); + $this->assertEquals($expected, $actual); + + $parser = new Api4ArgParser(); + $roundtrip = $parser->parse($actual); + $this->assertEquals($input, $roundtrip, 'Round-trip of encoding and parsing should yield original'); + } + +} From 32817edbb98437cc4e0c9ada544b1e830f97eeac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 Nov 2024 20:38:36 +0000 Subject: [PATCH 28/90] Bump symfony/process from 5.4.40 to 5.4.46 Bumps [symfony/process](https://github.com/symfony/process) from 5.4.40 to 5.4.46. - [Release notes](https://github.com/symfony/process/releases) - [Changelog](https://github.com/symfony/process/blob/7.1/CHANGELOG.md) - [Commits](https://github.com/symfony/process/compare/v5.4.40...v5.4.46) --- updated-dependencies: - dependency-name: symfony/process dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- composer.lock | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/composer.lock b/composer.lock index 91d93a74..bf866e6a 100644 --- a/composer.lock +++ b/composer.lock @@ -935,20 +935,20 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "77fa7995ac1b21ab60769b7323d600a991a90433" + "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/77fa7995ac1b21ab60769b7323d600a991a90433", - "reference": "77fa7995ac1b21ab60769b7323d600a991a90433", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "type": "library", "extra": { @@ -995,7 +995,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" }, "funding": [ { @@ -1011,20 +1011,20 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/process", - "version": "v5.4.40", + "version": "v5.4.46", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "deedcb3bb4669cae2148bc920eafd2b16dc7c046" + "reference": "01906871cb9b5e3cf872863b91aba4ec9767daf4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/deedcb3bb4669cae2148bc920eafd2b16dc7c046", - "reference": "deedcb3bb4669cae2148bc920eafd2b16dc7c046", + "url": "https://api.github.com/repos/symfony/process/zipball/01906871cb9b5e3cf872863b91aba4ec9767daf4", + "reference": "01906871cb9b5e3cf872863b91aba4ec9767daf4", "shasum": "" }, "require": { @@ -1057,7 +1057,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v5.4.40" + "source": "https://github.com/symfony/process/tree/v5.4.46" }, "funding": [ { @@ -1073,7 +1073,7 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:33:22+00:00" + "time": "2024-11-06T09:18:28+00:00" }, { "name": "symfony/service-contracts", From a6be83047eee0a3d9467b040d501125703ff9153 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 26 Nov 2024 17:01:21 -0800 Subject: [PATCH 29/90] php:boot - When using --level=classloader, propagate the $civicrm_root This complements civicrm-core#31512. The general idea is this: * When booting, you need to find the `vendor/autoload.php`. * If the site-build has symlinks, then you likely to search for `vendor/autoload.php` based on the *nominal* path (pre-symlink) rather than the *real* path (post-symlink). * We find the nominal path based on the declared `$civicrm_root`. * However, using `--level=classloader`, we don't have `civicrm.settings.php`, so we don't have `$civicrm_root` declared. So we cannot use it to search for `vendor/autoload.php`. * But if we provide a copy of `$civicrm_root` in the boots-script, then the search for `vendor/autoload.php` can proceed. --- src/Command/BootCommand.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Command/BootCommand.php b/src/Command/BootCommand.php index 60dc8df1..735e0657 100644 --- a/src/Command/BootCommand.php +++ b/src/Command/BootCommand.php @@ -15,8 +15,11 @@ protected function configure() { protected function execute(InputInterface $input, OutputInterface $output): int { switch ($input->getOption('level')) { case 'classloader': - $code = sprintf('require_once %s . "/CRM/Core/ClassLoader.php";', var_export(rtrim($GLOBALS["civicrm_root"], '/'), 1)) - . '\CRM_Core_ClassLoader::singleton()->register();'; + $code = implode("\n", [ + sprintf('$GLOBALS["civicrm_root"] = %s;', var_export(rtrim($GLOBALS["civicrm_root"], '/'), 1)), + 'require_once $GLOBALS["civicrm_root"] . "/CRM/Core/ClassLoader.php";', + '\CRM_Core_ClassLoader::singleton()->register();', + ]); break; case 'settings': From 621e01e53abc45b9b16da08f003657ad5309408e Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 26 Nov 2024 19:35:41 -0800 Subject: [PATCH 30/90] PHP 8.4 - Fix some more warnings --- lib/src/Util/CvArgvInput.php | 2 +- tests/Util/OptionalOptionTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/Util/CvArgvInput.php b/lib/src/Util/CvArgvInput.php index 72f122d5..1df6b718 100644 --- a/lib/src/Util/CvArgvInput.php +++ b/lib/src/Util/CvArgvInput.php @@ -12,7 +12,7 @@ class CvArgvInput extends ArgvInput { protected $originalArgv; - public function __construct(array $argv = NULL, InputDefinition $definition = NULL) { + public function __construct(?array $argv = NULL, ?InputDefinition $definition = NULL) { $argv = $argv ?? $_SERVER['argv'] ?? []; $this->originalArgv = $argv; parent::__construct($argv, $definition); diff --git a/tests/Util/OptionalOptionTest.php b/tests/Util/OptionalOptionTest.php index 2f8f9c0b..0cf09468 100644 --- a/tests/Util/OptionalOptionTest.php +++ b/tests/Util/OptionalOptionTest.php @@ -55,7 +55,7 @@ public function testParse($inputArgv, $expectValue) { * @return array * [0 => InputInterface, 1 => OutputInterface] */ - protected function createInputOutput(array $argv = NULL): array { + protected function createInputOutput(?array $argv = NULL): array { $input = new ArgvInput($argv); $input->setInteractive(FALSE); $output = new NullOutput(); From d45e9c77d70adc9af68a1c1a3e5c3b69ae253a8e Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Thu, 19 Dec 2024 17:43:38 -0800 Subject: [PATCH 31/90] Debugging - Print details about SQL errors --- src/Application.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/Application.php b/src/Application.php index f47a5bb5..53fa0d9f 100644 --- a/src/Application.php +++ b/src/Application.php @@ -1,6 +1,8 @@ isVerbose() ? ' ' : ' '; /* Because, yeah, sure. */ + + $ei = $e; + while (is_callable([$ei, 'getCause'])) { + // DB_ERROR doesn't have a getCause but does have a __call function which tricks is_callable. + if (!$ei instanceof \DB_Error) { + if ($ei->getCause() instanceof \PEAR_Error) { + $output->writeln(sprintf("Debug Info:")); + $parts = explode("\n", $ei->getCause()->getDebugInfo()); + foreach ($parts as $part) { + $output->writeln($indent . $part, OutputInterface::OUTPUT_RAW); + } + $output->writeln(''); + } + $ei = $ei->getCause(); + } + // if we have reached a DB_Error assume that is the end of the road. + else { + $ei = NULL; + } + } + } + } From 6855c03ed2416ef90202a39c3ce1efd7ccd68e20 Mon Sep 17 00:00:00 2001 From: Michael McAndrew Date: Tue, 14 Jan 2025 18:21:54 +0100 Subject: [PATCH 32/90] Set $_SERVER['SERVER_SOFTWARE'] to an empty string rather than NULL --- lib/src/Bootstrap.php | 2 +- lib/src/CmsBootstrap.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/Bootstrap.php b/lib/src/Bootstrap.php index 8b42215f..10ac196a 100644 --- a/lib/src/Bootstrap.php +++ b/lib/src/Bootstrap.php @@ -196,7 +196,7 @@ public function boot($options = array()) { $_SERVER['SCRIPT_FILENAME'] = $cmsBasePath . '/index.php'; $_SERVER['REMOTE_ADDR'] = "127.0.0.1"; - $_SERVER['SERVER_SOFTWARE'] = NULL; + $_SERVER['SERVER_SOFTWARE'] = ''; $_SERVER['REQUEST_METHOD'] = 'GET'; if (!empty($options['httpHost'])) { // Hint for D7 multisite diff --git a/lib/src/CmsBootstrap.php b/lib/src/CmsBootstrap.php index 85cf7b57..49911154 100644 --- a/lib/src/CmsBootstrap.php +++ b/lib/src/CmsBootstrap.php @@ -540,7 +540,7 @@ protected function getSearchDir() { protected function simulateWebEnv($host, $scriptFile) { $_SERVER['SCRIPT_FILENAME'] = $scriptFile; $_SERVER['REMOTE_ADDR'] = "127.0.0.1"; - $_SERVER['SERVER_SOFTWARE'] = NULL; + $_SERVER['SERVER_SOFTWARE'] = ''; $_SERVER['REQUEST_METHOD'] = 'GET'; $_SERVER['SERVER_NAME'] = $host; $_SERVER['HTTP_HOST'] = $host; From 214db40c1df1a66238626dd125d2879558d33084 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Fri, 17 Jan 2025 15:55:39 -0800 Subject: [PATCH 33/90] DebugContainerCommandTest - Relax examples This test is looking or a particular example data that is removed in 6.0. Make it more forgiving. --- tests/Command/DebugContainerCommandTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Command/DebugContainerCommandTest.php b/tests/Command/DebugContainerCommandTest.php index 630efb02..4196df9a 100644 --- a/tests/Command/DebugContainerCommandTest.php +++ b/tests/Command/DebugContainerCommandTest.php @@ -14,7 +14,7 @@ public function setUp(): void { public function testShowAll() { $p = Process::runOk($this->cv("debug:container")); - $this->assertMatchesRegularExpression('/cxn_reg_client.*Civi.Cxn.Rpc.RegistrationClient/', $p->getOutput()); + $this->assertMatchesRegularExpression('/(cxn_reg_client.*Civi.Cxn.Rpc.RegistrationClient|httpClient.*CRM_Utils_HttpClient|sql_triggers.*Civi.Core.SqlTrigger)/', $p->getOutput()); $this->assertMatchesRegularExpression('/civi_api_kernel.*Civi.API.Kernel/', $p->getOutput()); } From a4567459a381df6fc193da1a52b5e1998897ef97 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Fri, 17 Jan 2025 16:24:23 -0800 Subject: [PATCH 34/90] Bootstrap - Keep SERVER_SOFTWARE=NULL for Drupal 7 --- lib/src/Bootstrap.php | 2 +- lib/src/CmsBootstrap.php | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/src/Bootstrap.php b/lib/src/Bootstrap.php index 10ac196a..d3c06979 100644 --- a/lib/src/Bootstrap.php +++ b/lib/src/Bootstrap.php @@ -196,7 +196,7 @@ public function boot($options = array()) { $_SERVER['SCRIPT_FILENAME'] = $cmsBasePath . '/index.php'; $_SERVER['REMOTE_ADDR'] = "127.0.0.1"; - $_SERVER['SERVER_SOFTWARE'] = ''; + $_SERVER['SERVER_SOFTWARE'] = ($cmsType === 'drupal') ? NULL : ''; $_SERVER['REQUEST_METHOD'] = 'GET'; if (!empty($options['httpHost'])) { // Hint for D7 multisite diff --git a/lib/src/CmsBootstrap.php b/lib/src/CmsBootstrap.php index 49911154..814e2c5f 100644 --- a/lib/src/CmsBootstrap.php +++ b/lib/src/CmsBootstrap.php @@ -161,7 +161,10 @@ public function bootCms() { if (PHP_SAPI === "cli") { $this->log->debug("Simulate web environment in CLI"); - $this->simulateWebEnv($this->options['httpHost'], $cms['path'] . '/index.php'); + $this->simulateWebEnv($this->options['httpHost'], + $cms['path'] . '/index.php', + ($cms['type'] === 'Drupal') ? NULL : '' + ); } $originalErrorReporting = error_reporting(); @@ -534,13 +537,14 @@ protected function getSearchDir() { } /** - * @param $scriptFile - * @param $host + * @param string $host + * @param string $scriptFile + * @param string $serverSoftware */ - protected function simulateWebEnv($host, $scriptFile) { + protected function simulateWebEnv($host, $scriptFile, $serverSoftware) { $_SERVER['SCRIPT_FILENAME'] = $scriptFile; $_SERVER['REMOTE_ADDR'] = "127.0.0.1"; - $_SERVER['SERVER_SOFTWARE'] = ''; + $_SERVER['SERVER_SOFTWARE'] = $serverSoftware; $_SERVER['REQUEST_METHOD'] = 'GET'; $_SERVER['SERVER_NAME'] = $host; $_SERVER['HTTP_HOST'] = $host; From 5da97968a75a74dd5e1bd39c1c5d02ddfce4c23d Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 4 Feb 2025 23:34:35 -0800 Subject: [PATCH 35/90] Debugging - Print details about SQL errors (more compat) * Better support for DBQueryException * Better support for chained exceptions (`getPrevious()`) * NOTE: `getCause()` and `getPrevious()` sound similar, but actually differ: * `getCause()` is a PEAR thing. It unwraps the PEAR_Error that provoked the Exception * `getPrevious()` is a PHP stdlib thing. It the earlier excetion. --- src/Application.php | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/Application.php b/src/Application.php index 53fa0d9f..ff6ec3e4 100644 --- a/src/Application.php +++ b/src/Application.php @@ -104,23 +104,24 @@ public function doRenderThrowable(\Throwable $e, OutputInterface $output): void $indent = $output->isVerbose() ? ' ' : ' '; /* Because, yeah, sure. */ + $causes = []; $ei = $e; - while (is_callable([$ei, 'getCause'])) { - // DB_ERROR doesn't have a getCause but does have a __call function which tricks is_callable. - if (!$ei instanceof \DB_Error) { - if ($ei->getCause() instanceof \PEAR_Error) { - $output->writeln(sprintf("Debug Info:")); - $parts = explode("\n", $ei->getCause()->getDebugInfo()); - foreach ($parts as $part) { - $output->writeln($indent . $part, OutputInterface::OUTPUT_RAW); - } - $output->writeln(''); - } - $ei = $ei->getCause(); + while ($ei !== NULL) { + if (method_exists($ei, 'getCause')) { + $causes[] = $ei->getCause(); } - // if we have reached a DB_Error assume that is the end of the road. - else { - $ei = NULL; + $ei = $ei->getPrevious(); + } + + foreach ($causes as $cause) { + if ($cause instanceof \DB_Error && !empty($cause->getDebugInfo())) { + $output->writeln(sprintf("Debug Info:")); + $parts = explode("\n", $cause->getDebugInfo()); + foreach ($parts as $part) { + $output->writeln($indent . $part, OutputInterface::OUTPUT_RAW); + } + $output->writeln(''); + break; } } } From f912b4585945380cc7907c5649151ad821f4ba66 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 4 Feb 2025 23:48:04 -0800 Subject: [PATCH 36/90] scoper.inc.php - PEAR_Error and PEAR_Exception are always global symbols --- scoper.inc.php | 1 + 1 file changed, 1 insertion(+) diff --git a/scoper.inc.php b/scoper.inc.php index e7a94846..d6b69c11 100644 --- a/scoper.inc.php +++ b/scoper.inc.php @@ -21,6 +21,7 @@ ], 'exclude-classes' => [ '/^(CRM_|HTML_|DB_|Log_)/', + '/^PEAR_(Error|Exception)/', 'DB', 'Log', 'JFactory', From 4fd6c6b24223f1361e0f63ea5071c507ff34f200 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Thu, 6 Feb 2025 00:27:40 -0800 Subject: [PATCH 37/90] Add subcommand "status" to report key facts --- src/Application.php | 1 + src/Command/StatusCommand.php | 107 ++++++++++++++++++++++++++++ tests/Command/StatusCommandTest.php | 28 ++++++++ 3 files changed, 136 insertions(+) create mode 100644 src/Command/StatusCommand.php create mode 100644 tests/Command/StatusCommandTest.php diff --git a/src/Application.php b/src/Application.php index ff6ec3e4..6a6f6ca3 100644 --- a/src/Application.php +++ b/src/Application.php @@ -71,6 +71,7 @@ public function createCommands($context = 'default') { $commands[] = new \Civi\Cv\Command\SettingRevertCommand(); $commands[] = new \Civi\Cv\Command\SqlCliCommand(); $commands[] = new \Civi\Cv\Command\ShowCommand(); + $commands[] = new \Civi\Cv\Command\StatusCommand(); // $commands[] = new \Civi\Cv\Command\UpgradeCommand(); $commands[] = new \Civi\Cv\Command\UpgradeDbCommand(); // $commands[] = new \Civi\Cv\Command\UpgradeDlCommand(); diff --git a/src/Command/StatusCommand.php b/src/Command/StatusCommand.php new file mode 100644 index 00000000..033ebc6c --- /dev/null +++ b/src/Command/StatusCommand.php @@ -0,0 +1,107 @@ +setName('status') + ->setDescription('Provide an overview of current site/environment') + ->configureOutputOptions(['tabular' => TRUE, 'fallback' => 'table']); + + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $isPhar = preg_match(';^phar://;', __FILE__); + $civiCodeVer = \CRM_Utils_System::version(); + $civiDbVer = \CRM_Core_BAO_Domain::version(); + $mysqlVersion = \CRM_Utils_SQL::getDatabaseVersion(); + $ufType = strtolower((CIVICRM_UF === 'Drupal8') ? 'Drupal' : CIVICRM_UF); + $ufVer = \CRM_Core_Config::singleton()->userSystem->getVersion(); + if (method_exists(\CRM_Core_Smarty::singleton(), 'getVersion')) { + $smartyVer = \CRM_Core_Smarty::singleton()->getVersion(); + } + else { + $smartyVer = 'Unknown'; + } + + $summaryCode = sprintf('%s%s %s%s %s %s', + $civiCodeVer, + ($civiCodeVer === $civiDbVer) ? '' : '**', + $this->shortPhp(PHP_VERSION), + $this->shortDbms($mysqlVersion), + $this->shortCms(CIVICRM_UF, $ufVer), + strtolower(php_uname('s')) + ); + + $data = []; + $data['summary'] = $summaryCode; + $data['civicrm'] = ($civiDbVer === $civiCodeVer) ? "$civiCodeVer" : "$civiCodeVer (DB $civiDbVer)"; + $data['cv'] = Application::version() . ($isPhar ? ' (phar)' : ' (src)'); + $data['php'] = sprintf('%s (%s)', PHP_VERSION, PHP_SAPI); + $data['mysql'] = $mysqlVersion; + $data[$ufType] = $ufVer; + $data['os'] = php_uname('s') . ' ' . php_uname('r') . ' ' . php_uname('m'); + // Would be nice to get lsb_release, but that requires more conditionality + $data['smarty'] = $smartyVer; + $data['path: cms.root'] = \Civi::paths()->getPath('[cms.root]/.'); + $data['path: civicrm.root'] = \Civi::paths()->getPath('[civicrm.root]/.'); + $data['path: civicrm.log'] = \Civi::paths()->getPath('[civicrm.log]/.'); + $data['path: civicrm.l10n'] = \Civi::paths()->getPath('[civicrm.l10n]/.'); + $data['path: extensionsDir'] = \CRM_Core_Config::singleton()->extensionsDir; + + $rows = []; + foreach ($data as $name => $value) { + $rows[$name] = ['name' => $name, 'value' => $value]; + } + + $this->sendTable($input, $output, $rows); + return 0; + } + + private function shortPhp($version): string { + return 'php' . preg_replace('/([0-9]+)\.([0-9]+).*$/', '$1$2', $version); + } + + private function shortDbms($version): string { + if (str_contains($version, 'Maria')) { + // FIXME: ex: 10.5 ==> r105 + return 'r???'; + } + else { + return 'm' . preg_replace('/([0-9]+)\.([0-9]+).*$/', '$1$2', $version); + } + } + + private function shortCms($ufName, $ufVersion): string { + switch ($ufName) { + case 'Drupal': + case 'Drupal8': + return 'drupal' . explode('.', $ufVersion)[0]; + + case 'WordPress': + return 'wp'; + + case 'Joomla': + return 'joomla' . explode('.', $ufVersion)[0]; + + case 'Backdrop': + return 'backdrop'; + + case 'Standalone': + return 'standalone'; + + default: + return $ufName; + } + } + +} diff --git a/tests/Command/StatusCommandTest.php b/tests/Command/StatusCommandTest.php new file mode 100644 index 00000000..5ad4102e --- /dev/null +++ b/tests/Command/StatusCommandTest.php @@ -0,0 +1,28 @@ +cv("status"); + $p->run(); + $data = $p->getOutput(); + $this->assertTrue((bool) preg_match('/| php .* | \d+\.\d+\./', $data)); + } + + public function testStatusJson() { + $p = $this->cv("status --out=json"); + $p->run(); + $data = json_decode($p->getOutput(), 1); + $this->assertTrue((bool) preg_match('/^\d+\.\d+\./', $data['civicrm']['value'])); + $this->assertTrue((bool) preg_match('/^\d+\.\d+\./', $data['php']['value'])); + } + +} From a34357af3d13f4f90862b0283b7476c8e04717c1 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 11 Feb 2025 22:16:39 -0800 Subject: [PATCH 38/90] "cv php:boot --level=cms-full" -- Drop redundant call to bootCivi() --- lib/src/CmsBootstrap.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/CmsBootstrap.php b/lib/src/CmsBootstrap.php index 814e2c5f..5b019afb 100644 --- a/lib/src/CmsBootstrap.php +++ b/lib/src/CmsBootstrap.php @@ -112,7 +112,7 @@ public function generate(array $actions = []): string { $code .= sprintf("require_once %s;\n", var_export(CV_AUTOLOAD, TRUE)); $code .= sprintf("%s->addOptions(%s);\n", $instanceExpr, var_export($this->getOptions(), TRUE)); foreach ($actions as $action) { - $code .= sprintf("%s->%s()->bootCivi();\n", $instanceExpr, $action); + $code .= sprintf("%s->%s();\n", $instanceExpr, $action); } return $code; } From 55b8cf16d017cf92dc266a9767a7134c191e1bba Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 11 Feb 2025 19:53:05 -0800 Subject: [PATCH 39/90] (NFC) Typo --- doc/plugins.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/plugins.md b/doc/plugins.md index 4f1ce90e..96be53d7 100644 --- a/doc/plugins.md +++ b/doc/plugins.md @@ -103,7 +103,7 @@ The `\Civi\Cv\Cv` facade provides some helpers for implementing functionality: * __Argument__: `$e['input']`: Reference to the `InputInterface` * __Argument__: `$e['output']`: Reference to the `OutputInterface` * __Argument__: `$e['argv']`: Raw/original arguments passed to the current command - * __Argument__: `$e['transport']`: Alternable callback (output). Fill in a value to specify how to forward the command to the referenced site. + * __Argument__: `$e['transport']`: Alterable callback (output). Fill in a value to specify how to forward the command to the referenced site. * __Argument__: `$e['exec']`: Non-alterable callback (input). Use this if you need to immediately call the action within the current process. (Note: When subscribing to an event like `cv.app.site-alias`, you may alternatively subscribe to the wildcard `*.app.site-alias`. In the future, this should allow you hook into adjacent commands like civix and coworker.) From 367ba85be9d585fc7f217f2a86ea7419a2dba433 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 11 Feb 2025 23:06:24 -0800 Subject: [PATCH 40/90] (NFC) Bootstrap --- lib/src/Bootstrap.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/src/Bootstrap.php b/lib/src/Bootstrap.php index d3c06979..fc8cd0dc 100644 --- a/lib/src/Bootstrap.php +++ b/lib/src/Bootstrap.php @@ -188,12 +188,11 @@ public function boot($options = array()) { } $this->log->notice("Find CMS root for \"" . $this->getSearchDir() . "\""); - list ($cmsType, $cmsBasePath) = $this->findCmsRoot($this->getSearchDir()); + [$cmsType, $cmsBasePath] = $this->findCmsRoot($this->getSearchDir()); $this->log->notice("Found \"$cmsType\" in \"$cmsBasePath\""); if (PHP_SAPI === "cli") { $this->log->notice("Simulate web environment in CLI"); - $_SERVER['SCRIPT_FILENAME'] = $cmsBasePath . '/index.php'; $_SERVER['REMOTE_ADDR'] = "127.0.0.1"; $_SERVER['SERVER_SOFTWARE'] = ($cmsType === 'drupal') ? NULL : ''; @@ -350,7 +349,7 @@ public function getCivicrmSettingsPhp($options) { $settings = $options['settingsFile']; } elseif (!empty($options['search'])) { - list (, , $settings) = $this->findCivicrmSettingsPhp($this->getSearchDir()); + [, , $settings] = $this->findCivicrmSettingsPhp($this->getSearchDir()); } return $settings; @@ -365,7 +364,7 @@ public function getCivicrmSettingsPhp($options) { * Array(string $cmsType, string $cmsRoot, string $settingsFile). */ protected function findCivicrmSettingsPhp($searchDir) { - list ($cmsType, $cmsRoot) = $this->findCmsRoot($searchDir); + [$cmsType, $cmsRoot] = $this->findCmsRoot($searchDir); $settings = NULL; switch ($cmsType) { case 'backdrop': From 6f6f91087c05d61cf341368e79f012124acb1289 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 11 Feb 2025 20:17:38 -0800 Subject: [PATCH 41/90] SetupCommandTrait - Fix warning on php82 --- src/Util/SetupCommandTrait.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Util/SetupCommandTrait.php b/src/Util/SetupCommandTrait.php index e7022d0a..971a8f1e 100644 --- a/src/Util/SetupCommandTrait.php +++ b/src/Util/SetupCommandTrait.php @@ -70,7 +70,9 @@ protected function bootSetupSubsystem(InputInterface $input, OutputInterface $ou $possibleSrcPaths[] = implode(DIRECTORY_SEPARATOR, [$b->getBootedCmsPath(), 'web', 'core']); $possibleSrcPaths[] = dirname($b->getBootedCmsPath()); } - $setupOptions['srcPath'] = ArrayUtil::pickFirst($possibleSrcPaths, 'file_exists'); + $setupOptions['srcPath'] = ArrayUtil::pickFirst($possibleSrcPaths, function($f) { + return $f !== NULL && file_exists($f); + }); if ($setupOptions['srcPath']) { $output->writeln(sprintf('Found code for %s in %s', 'civicrm-core', $setupOptions['srcPath']), $defaultOutputOptions); } @@ -88,7 +90,9 @@ protected function bootSetupSubsystem(InputInterface $input, OutputInterface $ou implode(DIRECTORY_SEPARATOR, [dirname($setupOptions['srcPath']), 'civicrm-setup']), implode(DIRECTORY_SEPARATOR, ['/usr', 'local', 'share', 'civicrm-setup']), ]; - $setupOptions['setupPath'] = ArrayUtil::pickFirst($possibleSetupPaths, 'file_exists'); + $setupOptions['setupPath'] = ArrayUtil::pickFirst($possibleSetupPaths, function($f) { + return $f !== NULL && file_exists($f); + }); if ($setupOptions['setupPath']) { $output->writeln(sprintf('Found code for %s in %s', 'civicrm-setup', $setupOptions['setupPath']), $defaultOutputOptions); } From 8e968f1205d8c8242bd772b2f692450e309c4105 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 12 Feb 2025 14:51:11 -0800 Subject: [PATCH 42/90] cv status - Add more info about PHP binary --- src/Command/StatusCommand.php | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/Command/StatusCommand.php b/src/Command/StatusCommand.php index 033ebc6c..49e60fb2 100644 --- a/src/Command/StatusCommand.php +++ b/src/Command/StatusCommand.php @@ -46,7 +46,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $data['summary'] = $summaryCode; $data['civicrm'] = ($civiDbVer === $civiCodeVer) ? "$civiCodeVer" : "$civiCodeVer (DB $civiDbVer)"; $data['cv'] = Application::version() . ($isPhar ? ' (phar)' : ' (src)'); - $data['php'] = sprintf('%s (%s)', PHP_VERSION, PHP_SAPI); + $data['php'] = $this->longPhp(); $data['mysql'] = $mysqlVersion; $data[$ufType] = $ufVer; $data['os'] = php_uname('s') . ' ' . php_uname('r') . ' ' . php_uname('m'); @@ -67,6 +67,36 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } + private function longPhp(): string { + $parens = [PHP_SAPI => 1]; + + if (file_exists('/.dockerenv')) { + $parens['docker'] = 1; + } + + $parens['other'] = 1; + foreach ([PHP_BINARY, realpath(PHP_BINARY)] as $binary) { + if (preg_match(';^/nix/;', $binary)) { + $parens['nix'] = 1; + unset($parens['other']); + } + if (preg_match(';/homebrew/;', $binary)) { + $parens['homebrew'] = 1; + unset($parens['other']); + } + if (preg_match(';MAMP;', $binary)) { + $parens['mamp'] = 1; + unset($parens['other']); + } + if (preg_match(';^/usr/bin/;', $binary)) { + $parens['usr-bin'] = 1; + unset($parens['other']); + } + } + + return sprintf('%s (%s)', PHP_VERSION, implode(', ', array_keys($parens))); + } + private function shortPhp($version): string { return 'php' . preg_replace('/([0-9]+)\.([0-9]+).*$/', '$1$2', $version); } From 73ba4f5c750db322a21b787c4984897f08382d62 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 12 Feb 2025 15:04:06 -0800 Subject: [PATCH 43/90] cv status - Report more info about host OS --- src/Command/StatusCommand.php | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/Command/StatusCommand.php b/src/Command/StatusCommand.php index 49e60fb2..e8804844 100644 --- a/src/Command/StatusCommand.php +++ b/src/Command/StatusCommand.php @@ -6,6 +6,7 @@ use Civi\Cv\Util\StructuredOutputTrait; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\Process; class StatusCommand extends CvCommand { @@ -49,7 +50,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $data['php'] = $this->longPhp(); $data['mysql'] = $mysqlVersion; $data[$ufType] = $ufVer; - $data['os'] = php_uname('s') . ' ' . php_uname('r') . ' ' . php_uname('m'); + $data['os'] = $this->longOs(); // Would be nice to get lsb_release, but that requires more conditionality $data['smarty'] = $smartyVer; $data['path: cms.root'] = \Civi::paths()->getPath('[cms.root]/.'); @@ -67,6 +68,31 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } + private function longOs(): string { + $parens = []; + + $p = new Process(['lsb_release', '-sd']); + $p->run(); + if ($p->isSuccessful() && $output = trim($p->getOutput())) { + $main = $output; + $parens[php_uname('s') . ' ' . php_uname('r')] = 1; + } + else { + $main = php_uname('s') . ' ' . php_uname('r'); + } + + if (file_exists('/.dockerenv')) { + $parens['docker'] = 1; + } + if (file_exists('/nix')) { + $parens['nix'] = 1; + } + + $parens[php_uname('m')] = 1; + + return sprintf('%s (%s)', $main, implode(', ', array_keys($parens))); + } + private function longPhp(): string { $parens = [PHP_SAPI => 1]; From f0adcc6a3af929140f6e833121dea3c9ba2783d1 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 12 Feb 2025 15:20:20 -0800 Subject: [PATCH 44/90] cv status - Clarify when DB is older or newer than code --- src/Command/StatusCommand.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Command/StatusCommand.php b/src/Command/StatusCommand.php index e8804844..dd3d8d1e 100644 --- a/src/Command/StatusCommand.php +++ b/src/Command/StatusCommand.php @@ -45,7 +45,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $data = []; $data['summary'] = $summaryCode; - $data['civicrm'] = ($civiDbVer === $civiCodeVer) ? "$civiCodeVer" : "$civiCodeVer (DB $civiDbVer)"; + $data['civicrm'] = $this->longCivi($civiCodeVer, $civiDbVer); $data['cv'] = Application::version() . ($isPhar ? ' (phar)' : ' (src)'); $data['php'] = $this->longPhp(); $data['mysql'] = $mysqlVersion; @@ -68,6 +68,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } + private function longCivi($civiCodeVer, $civiDbVer): string { + if ($civiDbVer === $civiCodeVer) { + return $civiCodeVer; + } + elseif (version_compare($civiDbVer, $civiCodeVer, '<')) { + return "$civiCodeVer (pending upgrade from $civiDbVer)"; + } + else { + return "$civiCodeVer (futuristic data from $civiDbVer)"; + } + } + private function longOs(): string { $parens = []; From af0a8e9a16f399694bb3512932dc71695ab743b7 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 19 Feb 2025 15:44:49 -0800 Subject: [PATCH 45/90] cv status - Tweak os+php output --- src/Command/StatusCommand.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Command/StatusCommand.php b/src/Command/StatusCommand.php index dd3d8d1e..6026aefc 100644 --- a/src/Command/StatusCommand.php +++ b/src/Command/StatusCommand.php @@ -93,15 +93,19 @@ private function longOs(): string { $main = php_uname('s') . ' ' . php_uname('r'); } + $parens[php_uname('m')] = 1; + if (file_exists('/.dockerenv')) { $parens['docker'] = 1; } + if (file_exists('/opt/homebrew')) { + // Newer deployments use /opt/homebrew. Dunno how to check older deployments in /usr/local. + $parens['homebrew'] = 1; + } if (file_exists('/nix')) { $parens['nix'] = 1; } - $parens[php_uname('m')] = 1; - return sprintf('%s (%s)', $main, implode(', ', array_keys($parens))); } @@ -119,6 +123,7 @@ private function longPhp(): string { unset($parens['other']); } if (preg_match(';/homebrew/;', $binary)) { + // Newer deployments use /opt/homebrew. Dunno how to check older deployments in /usr/local. $parens['homebrew'] = 1; unset($parens['other']); } From ab0c1f89d42a2708a1838e30a8c44a1ae7e9b1e7 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 19 Feb 2025 15:14:45 -0800 Subject: [PATCH 46/90] cv status - More paths and URLs. Sort paths to show alignments. --- src/Command/StatusCommand.php | 61 ++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/src/Command/StatusCommand.php b/src/Command/StatusCommand.php index 6026aefc..34449d09 100644 --- a/src/Command/StatusCommand.php +++ b/src/Command/StatusCommand.php @@ -4,6 +4,7 @@ use Civi\Cv\Application; use Civi\Cv\Util\StructuredOutputTrait; +use Civi\Test\Invasive; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\Process; @@ -53,11 +54,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $data['os'] = $this->longOs(); // Would be nice to get lsb_release, but that requires more conditionality $data['smarty'] = $smartyVer; - $data['path: cms.root'] = \Civi::paths()->getPath('[cms.root]/.'); - $data['path: civicrm.root'] = \Civi::paths()->getPath('[civicrm.root]/.'); - $data['path: civicrm.log'] = \Civi::paths()->getPath('[civicrm.log]/.'); - $data['path: civicrm.l10n'] = \Civi::paths()->getPath('[civicrm.l10n]/.'); - $data['path: extensionsDir'] = \CRM_Core_Config::singleton()->extensionsDir; + $data = array_merge($data, $this->findPathsUrls($output)); $rows = []; foreach ($data as $name => $value) { @@ -65,6 +62,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $this->sendTable($input, $output, $rows); + if ($input->getOption('out') === 'table' && !$output->isVerbose()) { + $error = method_exists($output, 'getErrorOutput') ? $output->getErrorOutput() : $output; + $error->writeln('TIP: To see even more information, enable the verbose flag (-v).'); + } + return 0; } @@ -177,4 +179,53 @@ private function shortCms($ufName, $ufVersion): string { } } + /** + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return array + */ + protected function findPathsUrls(OutputInterface $output): array { + $error = method_exists($output, 'getErrorOutput') ? $output->getErrorOutput() : $output; + $pathList = $urlList = []; + $paths = \Civi::paths(); + + // These are paths that a sysadmin is likely to need to consult while debugging common problems. + $pathVariables = ['cms.root', 'civicrm.root', 'civicrm.log', 'civicrm.l10n']; + $urlVariables = []; + // In default (non-verbose) mode, we don't automatically print most URLs because + // most URL-detection is HTTP-dependent. Interpreting that data takes more effort/attention. + + if ($output->isVerbose()) { + $allVariables = property_exists($paths, 'variableFactory') ? Invasive::get([$paths, 'variableFactory']) : NULL; + if (empty($allVariables)) { + $error->writeln('Failed to inspect Civi::paths()->variableFactory'); + } + else { + $pathVariables = $urlVariables = array_keys($allVariables); + } + } + + foreach ($urlVariables as $variable) { + try { + $urlList['url: [' . $variable . ']'] = $paths->getUrl('[' . $variable . ']/.'); + } + catch (\Throwable $e) { + } + } + foreach ($pathVariables as $variable) { + try { + $pathList['path: [' . $variable . ']'] = $paths->getPath('[' . $variable . ']/.'); + } + catch (\Throwable $e) { + } + } + + // Oddballs + $urlList['url: CIVICRM_UF_BASEURL'] = \CRM_Utils_Constant::value('CIVICRM_UF_BASEURL'); + $pathList['path: extensionsDir'] = \CRM_Core_Config::singleton()->extensionsDir; + + asort($pathList); + asort($urlList); + return array_merge($pathList, $urlList); + } + } From 0b94a605f77f4f9eb9cda836bcb4fb42256b5e4a Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 4 Feb 2025 18:52:16 -0800 Subject: [PATCH 47/90] Bootstrap.php - Simulate HTTPS, SERVER_PORT on WordPress --- lib/src/Bootstrap.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/src/Bootstrap.php b/lib/src/Bootstrap.php index fc8cd0dc..98aad39c 100644 --- a/lib/src/Bootstrap.php +++ b/lib/src/Bootstrap.php @@ -214,7 +214,7 @@ public function boot($options = array()) { throw new \Exception("Could not load the CiviCRM settings file: {$settings}"); } - if (empty($_SERVER['HTTP_HOST']) && $cmsType === 'backdrop') { + if (empty($_SERVER['HTTP_HOST']) && in_array($cmsType, ['backdrop', 'wp'])) { // backdrop_settings_initialize() tries to configure cookie policy - and complains if HTTP_HOST is missing $webHostVars = $this->convertUrlToCgiVars(defined('CIVICRM_UF_BASEURL') ? CIVICRM_UF_BASEURL : 'http://localhost'); foreach ($webHostVars as $key => $value) { @@ -254,6 +254,9 @@ private function convertUrlToCgiVars(string $url): array { $result['HTTP_HOST'] = $parts['host']; $result['SERVER_PORT'] = $parts['scheme'] === 'http' ? 80 : 443; } + if ($parts['scheme'] === 'https') { + $result['HTTPS'] = 'on'; + } return $result; } @@ -276,7 +279,7 @@ public function generate() { 'REQUEST_METHOD', 'SCRIPT_NAME', ); - if (CIVICRM_UF === 'Backdrop') { + if (in_array(CIVICRM_UF, ['Backdrop', 'WordPress'])) { $webHostVars = $this->convertUrlToCgiVars(defined('CIVICRM_UF_BASEURL') ? CIVICRM_UF_BASEURL : 'http://localhost'); $srvVars = array_merge($srvVars, array_keys($webHostVars)); // ^^ This might make sense for all UF's, but it would require more testing to QA. From 5049dc011df282d4db85deeb93022577976e8772 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 11 Feb 2025 20:02:42 -0800 Subject: [PATCH 48/90] CmsBootstrap - For simulated web-env, accept fully formed URL (part 1) --- lib/src/CmsBootstrap.php | 72 ++++++++++++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/lib/src/CmsBootstrap.php b/lib/src/CmsBootstrap.php index 5b019afb..f2358ae3 100644 --- a/lib/src/CmsBootstrap.php +++ b/lib/src/CmsBootstrap.php @@ -34,7 +34,8 @@ * Boolean TRUE means it should use a default (PWD). * (Default: TRUE aka PWD) * - user: string|NULL. The name of a CMS user to authenticate as. - * - httpHost: string|NULL. For multisite, the HTTP hostname. + * - url: string|NULL. Specify the logical URL being used to process this request + * - httpHost: string|NULL. Specify the logical URL being used to process this request (DEPRECATED; prefer "url") * - log: \Psr\Log\LoggerInterface|\Civi\Cv\Log\InternalLogger (If given, send log messages here) * - output: Symfony OutputInterface. (Fallback for handling logs - in absence of 'log') * @@ -82,7 +83,7 @@ public static function singleton() { self::$singleton = new CmsBootstrap(array( 'env' => 'CIVICRM_BOOT', 'search' => TRUE, - 'httpHost' => array_key_exists('HTTP_HOST', $_SERVER) ? $_SERVER['HTTP_HOST'] : 'localhost', + 'url' => NULL, 'user' => NULL, )); } @@ -144,7 +145,7 @@ public function bootCms() { if (parse_url($cmsExpr, PHP_URL_QUERY)) { parse_str(parse_url($cmsExpr, PHP_URL_QUERY), $query); if (!empty($query['host'])) { - $this->options['httpHost'] = $query['host']; + $this->options['url'] = $query['host']; } } } @@ -161,7 +162,7 @@ public function bootCms() { if (PHP_SAPI === "cli") { $this->log->debug("Simulate web environment in CLI"); - $this->simulateWebEnv($this->options['httpHost'], + $this->simulateWebEnv($this->getEffectiveUrl(), $cms['path'] . '/index.php', ($cms['type'] === 'Drupal') ? NULL : '' ); @@ -173,8 +174,7 @@ public function bootCms() { throw new \Exception("Failed to locate boot function ($func)"); } - call_user_func([$this, $func], - $cms['path'], $this->options['user'], $this->options['httpHost']); + call_user_func([$this, $func], $cms['path'], $this->options['user']); if (PHP_SAPI === "cli") { error_reporting($originalErrorReporting); @@ -444,6 +444,10 @@ public function getOptions() { * @return CmsBootstrap */ public function addOptions($options) { + if (isset($options['httpHost'])) { + $options['url'] = $options['url'] ?? $options['httpHost']; + unset($options['httpHost']); + } $this->options = array_merge($this->options, $options); $this->log = Log\Logger::resolve($options, 'CmsBootstrap'); return $this; @@ -537,17 +541,65 @@ protected function getSearchDir() { } /** - * @param string $host + * Determine the effective site URL. We will use this to identify multisite systems, + * and it also inform URL construction. + * + * @return string + */ + protected function getEffectiveUrl(): string { + // (1) Initialize $url with 'scheme://example.com' or 'example.com'. + if (isset($this->options['url'])) { + $url = $this->options['url']; + } + elseif (array_key_exists('HTTP_HOST', $_SERVER) && strpos($_SERVER['HTTP_HOST'], '/') === FALSE) { + $url = $_SERVER['HTTP_HOST']; + if (array_key_exists('HTTP_PORT', $_SERVER)) { + $url .= $_SERVER['HTTP_PORT']; + } + } + elseif (defined('CIVICRM_UF_BASEURL')) { + $url = CIVICRM_UF_BASEURL; + } + else { + $url = 'localhost'; + } + + // (2) Backfill the scheme. + if (strpos($url, '://') === 0) { + return $url; + } + elseif (($_SERVER['SERVER_PORT'] ?? NULL) === 443 || ($_SERVER['HTTPS'] ?? NULL) === 'on') { + return 'https://' . $url; + } + else { + return 'http://' . $url; + } + } + + /** + * @param string $effectiveUrl * @param string $scriptFile * @param string $serverSoftware */ - protected function simulateWebEnv($host, $scriptFile, $serverSoftware) { + protected function simulateWebEnv($effectiveUrl, $scriptFile, $serverSoftware) { $_SERVER['SCRIPT_FILENAME'] = $scriptFile; $_SERVER['REMOTE_ADDR'] = "127.0.0.1"; $_SERVER['SERVER_SOFTWARE'] = $serverSoftware; $_SERVER['REQUEST_METHOD'] = 'GET'; - $_SERVER['SERVER_NAME'] = $host; - $_SERVER['HTTP_HOST'] = $host; + + $effectiveUrlParts = parse_url($effectiveUrl); + $_SERVER['SERVER_NAME'] = $effectiveUrlParts['host']; + $_SERVER['HTTP_HOST'] = $effectiveUrlParts['host']; + if ($effectiveUrlParts['scheme'] === 'https') { + $_SERVER['HTTPS'] = 'on'; + } + if (!empty($effectiveUrlParts['port'])) { + $_SERVER['SERVER_PORT'] = $effectiveUrlParts['port']; + } + else { + $_SERVER['SERVER_PORT'] = ($effectiveUrlParts['host'] === 'https') ? '443' : '80'; + } + if (ord($_SERVER['SCRIPT_NAME']) != 47) { $_SERVER['SCRIPT_NAME'] = '/' . $_SERVER['SCRIPT_NAME']; } From 0842d24ad52199c6b1efef65d3f4d0f383618f0a Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 11 Feb 2025 21:54:04 -0800 Subject: [PATCH 49/90] Combine --cms-base-url and --hostname into simpler --url --- README.md | 10 +++++----- doc/plugins.md | 2 +- lib/src/BaseApplication.php | 15 +++++++++++++++ lib/src/Command/CvCommand.php | 2 +- lib/src/Util/BootTrait.php | 10 +++++----- src/Command/CoreInstallCommand.php | 2 +- src/Util/SetupCommandTrait.php | 9 ++++----- tests/Command/CoreLifecycleTest.php | 4 ++-- 8 files changed, 34 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index efec556e..00c01c14 100644 --- a/README.md +++ b/README.md @@ -228,14 +228,14 @@ Bootstrap > ___NOTE___: In absence of a configuration variable, the __Automatic__ mode will behave like `CIVICRM_SETTINGS="Auto"` (in v0.3.x). This is tentatively planned to change in v0.4.x, where it will behave like `CIVICRM_BOOT="Auto://."` -Additionally, some deployments handle multiple sites ("multisite"/"multidomain"). You should target a specific site using `--hostname` or `HTTP_HOST`. +Additionally, some deployments handle multiple sites ("multisite"/"multidomain"). You should target a specific site using `--url` or `HTTP_HOST`. Here are a few examples of putting these together: ```bash -## Use --hostname for a domain +## Use --url for a domain export CIVICRM_BOOT="WordPress:/$HOME/public_html/" -cv --hostname='www.example.org' ext:list -L +cv --url='https://www.example.org' ext:list -L ``` ```bash @@ -246,9 +246,9 @@ cv ext:list -L ``` ```bash -## Use --hostname for a subfolder +## Use --url for a subfolder export CIVICRM_BOOT="WordPress:/$HOME/public_html/" -cv --hostname='www.example.org/nyc' ext:list -L +cv --url='www.example.org/nyc' ext:list -L ``` Autocomplete diff --git a/doc/plugins.md b/doc/plugins.md index 96be53d7..84e59605 100644 --- a/doc/plugins.md +++ b/doc/plugins.md @@ -113,7 +113,7 @@ The `\Civi\Cv\Cv` facade provides some helpers for implementing functionality: You can register new subcommands within `cv`. `cv` includes the base-class from Symfony Console, and its adds another base-class. Compare: * `CvDeps\Symfony\Component\Console\Command\Command` is the original building-block from Symfony Console. It can define and parse CLI arguments, but it does *not* bootstrap CiviCRM or CMS. It may be suitable for some basic commands. Documentation is provided by upstream. -* `Civi\Cv\Command\CvCommand` (v0.3.56+) is an extended version. It automatically boots CiviCRM and CMS. It handles common options like `--user`, `--hostname`, and `--level`, and it respect environment-variables like `CIVICRM_BOOT`. +* `Civi\Cv\Command\CvCommand` (v0.3.56+) is an extended version. It automatically boots CiviCRM and CMS. It handles common options like `--user`, `--url`, and `--level`, and it respect environment-variables like `CIVICRM_BOOT`. For this document, we focus on `CvCommand`. diff --git a/lib/src/BaseApplication.php b/lib/src/BaseApplication.php index dcc881e0..01d94094 100644 --- a/lib/src/BaseApplication.php +++ b/lib/src/BaseApplication.php @@ -28,6 +28,7 @@ public static function main(string $name, ?string $binDir, array $argv) { Cv::ioStack()->replace('app', $application); $application->configure(); $argv = AliasFilter::filter($argv); + $argv = static::filterDeprecatedOptions($argv); $result = $application->run(new CvArgvInput($argv), Cv::ioStack()->current('output')); } finally { @@ -142,4 +143,18 @@ protected function configureIO(InputInterface $input, OutputInterface $output) { }); } + protected static function filterDeprecatedOptions(array $argv): array { + foreach ($argv as &$arg) { + if (preg_match('/^--(cms-base-url|hostname)$/', $arg, $m)) { + Cv::io()->note(sprintf("Option --%s renamed to --url", $m[1])); + $arg = '--url'; + } + elseif (preg_match('/^--(cms-base-url|hostname)=(.*)/', $arg, $m)) { + Cv::io()->note(sprintf("Option --%s renamed to --url", $m[1])); + $arg = '--url=' . $m[2]; + } + } + return $argv; + } + } diff --git a/lib/src/Command/CvCommand.php b/lib/src/Command/CvCommand.php index d6bc5616..3be6e256 100644 --- a/lib/src/Command/CvCommand.php +++ b/lib/src/Command/CvCommand.php @@ -10,7 +10,7 @@ /** * `CvCommand` is a Symfony `Command` with support for bootstrapping CiviCRM/CMS. * - * - From end-user POV, the command accepts options like --user, --level, --hostname. + * - From end-user POV, the command accepts options like --user, --level, --url. * - From dev POV, the command allows you to implement `execute()` method without needing to * explicitly boot Civi. * - From dev POV, you may fine-tune command by changing the $bootOptions / getBootOptions(). diff --git a/lib/src/Util/BootTrait.php b/lib/src/Util/BootTrait.php index 6de9f647..1bcbd5b5 100644 --- a/lib/src/Util/BootTrait.php +++ b/lib/src/Util/BootTrait.php @@ -45,7 +45,7 @@ public function mergeDefaultBootDefinition($definition, $defaultLevel = 'full|cm // However, we also have extension-based commands. The system will boot before we have a chance to discover them. // By putting these options at the application level, we ensure they will be defined+used. $definition->addOption(new InputOption('level', NULL, InputOption::VALUE_REQUIRED, 'Bootstrap level (none,classloader,settings,full,cms-only,cms-full)', $defaultLevel)); - $definition->addOption(new InputOption('hostname', NULL, InputOption::VALUE_REQUIRED, 'Hostname (for a multisite system)')); + $definition->addOption(new InputOption('url', 'l', InputOption::VALUE_REQUIRED, 'URL or hostname of the current site (for a multisite system)')); $definition->addOption(new InputOption('test', 't', InputOption::VALUE_NONE, 'Bootstrap the test database (CIVICRM_UF=UnitTests)')); $definition->addOption(new InputOption('user', 'U', InputOption::VALUE_REQUIRED, 'CMS user')); } @@ -267,8 +267,8 @@ protected function createCmsBootstrap(InputInterface $input, OutputInterface $ou if ($input->getOption('user')) { $boot_params['user'] = $input->getOption('user'); } - if ($input->getOption('hostname')) { - $boot_params['httpHost'] = $input->getOption('hostname'); + if ($input->getOption('url')) { + $boot_params['url'] = $input->getOption('url'); } return \Civi\Cv\CmsBootstrap::singleton()->addOptions($boot_params); @@ -345,8 +345,8 @@ protected function createBootParams(InputInterface $input, OutputInterface $outp if ($output->isDebug()) { $boot_params['output'] = $output; } - if ($input->getOption('hostname')) { - $boot_params['httpHost'] = $input->getOption('hostname'); + if ($input->getOption('url')) { + $boot_params['url'] = $input->getOption('url'); } return $boot_params; } diff --git a/src/Command/CoreInstallCommand.php b/src/Command/CoreInstallCommand.php index 96e6bc24..64a33179 100644 --- a/src/Command/CoreInstallCommand.php +++ b/src/Command/CoreInstallCommand.php @@ -33,7 +33,7 @@ protected function configure() { $ wp plugin activate civicrm Example: Install on a basic Drupal 7 build. -$ cv core:install --cms-base-url=http://example.com/ +$ cv core:install --url=https://example.com/ $ drush -y en civicrm Example: Install on WordPress with a custom language and database. diff --git a/src/Util/SetupCommandTrait.php b/src/Util/SetupCommandTrait.php index 971a8f1e..de0329de 100644 --- a/src/Util/SetupCommandTrait.php +++ b/src/Util/SetupCommandTrait.php @@ -28,7 +28,6 @@ public function configureSetupOptions() { ->addOption('setup-path', NULL, InputOption::VALUE_OPTIONAL, 'The path to CivCRM-Setup source tree. (If omitted, read CV_SETUP_PATH or scan common defaults.)') ->addOption('src-path', NULL, InputOption::VALUE_OPTIONAL, 'The path to CivCRM-Core source tree. (If omitted, read CV_SETUP_SRC_PATH or scan common defaults.)') ->addOption('plugin-path', NULL, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'A directory with extra installer plugins') - ->addOption('cms-base-url', NULL, InputOption::VALUE_OPTIONAL, 'The URL of the CMS (If omitted, attempt to autodetect.)') ->addOption('lang', NULL, InputOption::VALUE_OPTIONAL, 'Specify the installation language') ->addOption('comp', NULL, InputOption::VALUE_OPTIONAL, 'Comma-separated list of CiviCRM components to enable. (Ex: CiviEvent,CiviContribute,CiviMember,CiviMail,CiviReport)') ->addOption('ext', NULL, InputOption::VALUE_OPTIONAL, 'Comma-separated list of CiviCRM extensions to enable. (Ex: org.civicrm.shoreditch,org.civicrm.flexmailer)') @@ -101,12 +100,12 @@ protected function bootSetupSubsystem(InputInterface $input, OutputInterface $ou throw new \Exception("Failed to locate civicrm-setup"); } - // Note: We set 'cms-base-url' both before and after init. The "before" + // Note: We set 'cmsBaseUrl' both before and after init. The "before" // lets us give hints to init code which reads cmsBaseUrl. The "after" // lets us override any changes made by init code (i.e. this user-input // is mandatory). - if ($input->getOption('cms-base-url')) { - $setupOptions['cmsBaseUrl'] = $input->getOption('cms-base-url'); + if ($input->getOption('url')) { + $setupOptions['cmsBaseUrl'] = $input->getOption('url'); } $pluginPaths = $this->buildPluginPaths($b, $input->getOption('plugin-path')); @@ -150,7 +149,7 @@ protected function bootSetupSubsystem(InputInterface $input, OutputInterface $ou $setup->getModel()->settingsPath, ]); $setup->getModel()->cmsBaseUrl = ArrayUtil::pickFirst([ - $input->getOption('cms-base-url'), + $input->getOption('url'), $setup->getModel()->cmsBaseUrl, ]); if ($input->getOption('db')) { diff --git a/tests/Command/CoreLifecycleTest.php b/tests/Command/CoreLifecycleTest.php index 7672c3d9..fce8f001 100644 --- a/tests/Command/CoreLifecycleTest.php +++ b/tests/Command/CoreLifecycleTest.php @@ -25,13 +25,13 @@ public function getTestCases() { $cases[] = [ 'backdrop-empty', ['modules' => 'https://download.civicrm.org/latest/civicrm-RC-backdrop.tar.gz'], - 'core:install -f --cms-base-url=http://localhost', + 'core:install -f --url=http://localhost', '', ]; $cases[] = [ 'drupal-empty', ['sites/all/modules' => 'https://download.civicrm.org/latest/civicrm-RC-drupal.tar.gz'], - 'core:install -f --cms-base-url=http://localhost', + 'core:install -f --url=http://localhost', // 'drush -y en civicrm', // No longer needed -- FlushDrupal plugin autoenables. '', ]; From 19a2f15cc964738eaf2656f0c52cd6bc72954baf Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 11 Feb 2025 23:12:22 -0800 Subject: [PATCH 50/90] (REF) Extract utility class (SimulateWeb) --- lib/src/Bootstrap.php | 39 +++------------ lib/src/CmsBootstrap.php | 70 ++------------------------ lib/src/Util/SimulateWeb.php | 95 ++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 97 deletions(-) create mode 100644 lib/src/Util/SimulateWeb.php diff --git a/lib/src/Bootstrap.php b/lib/src/Bootstrap.php index 98aad39c..ce1952eb 100644 --- a/lib/src/Bootstrap.php +++ b/lib/src/Bootstrap.php @@ -1,6 +1,8 @@ log->notice("Simulate web environment in CLI"); - $_SERVER['SCRIPT_FILENAME'] = $cmsBasePath . '/index.php'; - $_SERVER['REMOTE_ADDR'] = "127.0.0.1"; - $_SERVER['SERVER_SOFTWARE'] = ($cmsType === 'drupal') ? NULL : ''; - $_SERVER['REQUEST_METHOD'] = 'GET'; - if (!empty($options['httpHost'])) { - // Hint for D7 multisite - $_SERVER['HTTP_HOST'] = $options['httpHost']; - } - if (ord($_SERVER['SCRIPT_NAME']) != 47) { - $_SERVER['SCRIPT_NAME'] = '/' . $_SERVER['SCRIPT_NAME']; - } + $effectiveUrl = !empty($options['httpHost']) ? SimulateWeb::prependDefaultScheme($options['httpHost']) : NULL; + SimulateWeb::apply($effectiveUrl, + $cmsBasePath . '/index.php', + ($cmsType === 'drupal') ? NULL : ''); } $this->log->debug("Load settings file \"" . $settings . "\""); @@ -216,7 +211,7 @@ public function boot($options = array()) { if (empty($_SERVER['HTTP_HOST']) && in_array($cmsType, ['backdrop', 'wp'])) { // backdrop_settings_initialize() tries to configure cookie policy - and complains if HTTP_HOST is missing - $webHostVars = $this->convertUrlToCgiVars(defined('CIVICRM_UF_BASEURL') ? CIVICRM_UF_BASEURL : 'http://localhost'); + $webHostVars = SimulateWeb::convertUrlToCgiVars(defined('CIVICRM_UF_BASEURL') ? CIVICRM_UF_BASEURL : SimulateWeb::localhost()); foreach ($webHostVars as $key => $value) { $_SERVER[$key] = $value; } @@ -242,24 +237,6 @@ public function boot($options = array()) { $isBooting = FALSE; } - private function convertUrlToCgiVars(string $url): array { - $parts = parse_url($url); - $result = []; - $result['SERVER_NAME'] = $parts['host']; - if (!empty($parts['port'])) { - $result['HTTP_HOST'] = $parts['host'] . ':' . $parts['port']; - $result['SERVER_PORT'] = $parts['port']; - } - else { - $result['HTTP_HOST'] = $parts['host']; - $result['SERVER_PORT'] = $parts['scheme'] === 'http' ? 80 : 443; - } - if ($parts['scheme'] === 'https') { - $result['HTTPS'] = 'on'; - } - return $result; - } - /** * Generate bootstrap logic. * @@ -280,7 +257,7 @@ public function generate() { 'SCRIPT_NAME', ); if (in_array(CIVICRM_UF, ['Backdrop', 'WordPress'])) { - $webHostVars = $this->convertUrlToCgiVars(defined('CIVICRM_UF_BASEURL') ? CIVICRM_UF_BASEURL : 'http://localhost'); + $webHostVars = SimulateWeb::convertUrlToCgiVars(defined('CIVICRM_UF_BASEURL') ? CIVICRM_UF_BASEURL : SimulateWeb::localhost()); $srvVars = array_merge($srvVars, array_keys($webHostVars)); // ^^ This might make sense for all UF's, but it would require more testing to QA. } diff --git a/lib/src/CmsBootstrap.php b/lib/src/CmsBootstrap.php index f2358ae3..38bb2452 100644 --- a/lib/src/CmsBootstrap.php +++ b/lib/src/CmsBootstrap.php @@ -1,6 +1,8 @@ log->debug("Simulate web environment in CLI"); - $this->simulateWebEnv($this->getEffectiveUrl(), + $effectiveUrl = SimulateWeb::prependDefaultScheme($this->options['url'] ?? (SimulateWeb::detectEnvHost() ?: 'localhost')); + SimulateWeb::apply($effectiveUrl, $cms['path'] . '/index.php', ($cms['type'] === 'Drupal') ? NULL : '' ); @@ -540,71 +543,6 @@ protected function getSearchDir() { } } - /** - * Determine the effective site URL. We will use this to identify multisite systems, - * and it also inform URL construction. - * - * @return string - */ - protected function getEffectiveUrl(): string { - // (1) Initialize $url with 'scheme://example.com' or 'example.com'. - if (isset($this->options['url'])) { - $url = $this->options['url']; - } - elseif (array_key_exists('HTTP_HOST', $_SERVER) && strpos($_SERVER['HTTP_HOST'], '/') === FALSE) { - $url = $_SERVER['HTTP_HOST']; - if (array_key_exists('HTTP_PORT', $_SERVER)) { - $url .= $_SERVER['HTTP_PORT']; - } - } - elseif (defined('CIVICRM_UF_BASEURL')) { - $url = CIVICRM_UF_BASEURL; - } - else { - $url = 'localhost'; - } - - // (2) Backfill the scheme. - if (strpos($url, '://') === 0) { - return $url; - } - elseif (($_SERVER['SERVER_PORT'] ?? NULL) === 443 || ($_SERVER['HTTPS'] ?? NULL) === 'on') { - return 'https://' . $url; - } - else { - return 'http://' . $url; - } - } - - /** - * @param string $effectiveUrl - * @param string $scriptFile - * @param string $serverSoftware - */ - protected function simulateWebEnv($effectiveUrl, $scriptFile, $serverSoftware) { - $_SERVER['SCRIPT_FILENAME'] = $scriptFile; - $_SERVER['REMOTE_ADDR'] = "127.0.0.1"; - $_SERVER['SERVER_SOFTWARE'] = $serverSoftware; - $_SERVER['REQUEST_METHOD'] = 'GET'; - - $effectiveUrlParts = parse_url($effectiveUrl); - $_SERVER['SERVER_NAME'] = $effectiveUrlParts['host']; - $_SERVER['HTTP_HOST'] = $effectiveUrlParts['host']; - if ($effectiveUrlParts['scheme'] === 'https') { - $_SERVER['HTTPS'] = 'on'; - } - if (!empty($effectiveUrlParts['port'])) { - $_SERVER['SERVER_PORT'] = $effectiveUrlParts['port']; - } - else { - $_SERVER['SERVER_PORT'] = ($effectiveUrlParts['host'] === 'https') ? '443' : '80'; - } - - if (ord($_SERVER['SCRIPT_NAME']) != 47) { - $_SERVER['SCRIPT_NAME'] = '/' . $_SERVER['SCRIPT_NAME']; - } - } - protected function ensureUserContact() { if ($cid = \CRM_Core_Session::getLoggedInContactID()) { return $cid; diff --git a/lib/src/Util/SimulateWeb.php b/lib/src/Util/SimulateWeb.php new file mode 100644 index 00000000..2af7e277 --- /dev/null +++ b/lib/src/Util/SimulateWeb.php @@ -0,0 +1,95 @@ + $value) { + $_SERVER[$key] = $value; + } + } + + if (ord($_SERVER['SCRIPT_NAME']) != 47) { + $_SERVER['SCRIPT_NAME'] = '/' . $_SERVER['SCRIPT_NAME']; + } + } + + public static function convertUrlToCgiVars(?string $url): array { + if (strpos($url, '://') === FALSE) { + throw new \LogicException("convertUrlToCgiVars() expects a URL"); + } + + $parts = parse_url($url); + $result = []; + $result['SERVER_NAME'] = $parts['host']; + if (!empty($parts['port'])) { + $result['HTTP_HOST'] = $parts['host'] . ':' . $parts['port']; + $result['SERVER_PORT'] = $parts['port']; + } + else { + $result['HTTP_HOST'] = $parts['host']; + $result['SERVER_PORT'] = $parts['scheme'] === 'http' ? 80 : 443; + } + if ($parts['scheme'] === 'https') { + $result['HTTPS'] = 'on'; + } + return $result; + } + + public static function detectEnvUrl(): ?string { + if ($host = static::detectEnvHost()) { + return static::detectEnvScheme() . '://' . $host; + } + return NULL; + } + + /** + * If the user has environment-variables like HTTP_HOST, take that as a sign of + * the intended host. + * + * @return string|null + */ + public static function detectEnvHost(): ?string { + if (array_key_exists('HTTP_HOST', $_SERVER) && strpos($_SERVER['HTTP_HOST'], '//') === FALSE) { + $url = $_SERVER['HTTP_HOST']; + if (array_key_exists('HTTP_PORT', $_SERVER)) { + $url .= $_SERVER['HTTP_PORT']; + } + return $url; + } + return NULL; + } + + public static function detectEnvScheme(): ?string { + return (($_SERVER['SERVER_PORT'] ?? NULL) === 443 || ($_SERVER['HTTPS'] ?? NULL) === 'on') ? 'https' : 'http'; + } + + public static function prependDefaultScheme(?string $url): string { + if ($url === NULL || $url === '') { + return $url; + } + elseif (strpos($url, '://') !== FALSE) { + return $url; + } + else { + return static::detectEnvScheme() . '://' . $url; + } + } + + public static function localhost(): string { + return static::prependDefaultScheme('localhost'); + } + +} From 37b6ed6869354cfc6e18b5ece6ea35effdf0f096 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 11 Feb 2025 23:53:02 -0800 Subject: [PATCH 51/90] CmsBootstrap - For simulated web-env, accept fully formed URL (part 2) --- lib/src/CmsBootstrap.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/src/CmsBootstrap.php b/lib/src/CmsBootstrap.php index 38bb2452..f77c92e5 100644 --- a/lib/src/CmsBootstrap.php +++ b/lib/src/CmsBootstrap.php @@ -85,7 +85,7 @@ public static function singleton() { self::$singleton = new CmsBootstrap(array( 'env' => 'CIVICRM_BOOT', 'search' => TRUE, - 'url' => NULL, + 'url' => SimulateWeb::detectEnvUrl(), 'user' => NULL, )); } @@ -164,8 +164,7 @@ public function bootCms() { if (PHP_SAPI === "cli") { $this->log->debug("Simulate web environment in CLI"); - $effectiveUrl = SimulateWeb::prependDefaultScheme($this->options['url'] ?? (SimulateWeb::detectEnvHost() ?: 'localhost')); - SimulateWeb::apply($effectiveUrl, + SimulateWeb::apply($this->options['url'] ?? SimulateWeb::localhost(), $cms['path'] . '/index.php', ($cms['type'] === 'Drupal') ? NULL : '' ); @@ -451,6 +450,10 @@ public function addOptions($options) { $options['url'] = $options['url'] ?? $options['httpHost']; unset($options['httpHost']); } + if (isset($options['url'])) { + $options['url'] = SimulateWeb::prependDefaultScheme($options['url']); + } + $this->options = array_merge($this->options, $options); $this->log = Log\Logger::resolve($options, 'CmsBootstrap'); return $this; From e892118ea85fd78ef5b5150acfe32925ab0b9069 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 11 Feb 2025 23:53:13 -0800 Subject: [PATCH 52/90] Bootstrap - For simulated web-env, accept fully formed URL --- lib/src/Bootstrap.php | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/lib/src/Bootstrap.php b/lib/src/Bootstrap.php index ce1952eb..f4106ed6 100644 --- a/lib/src/Bootstrap.php +++ b/lib/src/Bootstrap.php @@ -63,7 +63,8 @@ * - env: string|NULL. The environment variable which may contain the path to * civicrm.settings.php (or the token "Auto"). Set NULL to disable environment-checking. * (Default: CIVICRM_SETTINGS) - * - httpHost: string|NULL. For multisite, the HTTP hostname. + * - url: string|NULL. Specify the logical URL being used to process this request + * - httpHost: string|NULL. For multisite, the HTTP hostname. (DEPRECATED; prefer "url") * - log: \Psr\Log\LoggerInterface|\Civi\Cv\Log\InternalLogger (If given, send log messages here) * - output: Symfony OutputInterface. (Fallback for handling logs - in absence of 'log') * - prefetch: bool. Whether to load various caches. @@ -113,7 +114,7 @@ public static function singleton() { 'settingsFile' => NULL, 'search' => TRUE, 'cmsType' => NULL, - 'httpHost' => array_key_exists('HTTP_HOST', $_SERVER) ? $_SERVER['HTTP_HOST'] : '', + 'url' => SimulateWeb::detectEnvUrl(), )); } return self::$singleton; @@ -124,7 +125,7 @@ public static function singleton() { * See options in class doc. */ public function __construct($options = array()) { - $this->options = $options; + $this->setOptions($options); } /** @@ -135,6 +136,7 @@ public function __construct($options = array()) { * @throws \Exception */ public function boot($options = array()) { + $options = $this->filterOptions($options); $this->log = Log\Logger::resolve($options, 'Bootstrap'); $isBooting = TRUE; @@ -195,10 +197,10 @@ public function boot($options = array()) { if (PHP_SAPI === "cli") { $this->log->notice("Simulate web environment in CLI"); - $effectiveUrl = !empty($options['httpHost']) ? SimulateWeb::prependDefaultScheme($options['httpHost']) : NULL; - SimulateWeb::apply($effectiveUrl, + SimulateWeb::apply($options['url'] ?? NULL, $cmsBasePath . '/index.php', ($cmsType === 'drupal') ? NULL : ''); + // NOTE: If we don't get explicit URL (env-var or cli-arg), then we leave HTTP_HOST blank -- and try to guess later. } $this->log->debug("Load settings file \"" . $settings . "\""); @@ -300,7 +302,18 @@ public function getOptions() { * See options in class doc. */ public function setOptions($options) { - $this->options = $options; + $this->options = $this->filterOptions($options); + } + + private function filterOptions($options) { + if (isset($options['httpHost'])) { + $options['url'] = $options['url'] ?? $options['httpHost']; + unset($options['httpHost']); + } + if (isset($options['url'])) { + $options['url'] = SimulateWeb::prependDefaultScheme($options['url']); + } + return $options; } /** @@ -406,10 +419,12 @@ protected function findCivicrmSettingsPhp($searchDir) { * @return array */ protected function findDrupalDirs($cmsRoot, $searchDir) { + $httpHost = empty($this->options['url']) ? '' : parse_url($this->options['url'], PHP_URL_HOST); + // If there's no explicit host and we start the search from "web/sites/FOO/...", then infer subsite path. $sitesRoot = "$cmsRoot/sites"; $sitesRootQt = preg_quote($sitesRoot, ';'); - if (empty($this->options['httpHost']) && preg_match(";^($sitesRootQt/[^/]+);", $searchDir, $m)) { + if (empty($httpHost) && preg_match(";^($sitesRootQt/[^/]+);", $searchDir, $m)) { if (basename($m[1]) !== 'all') { return [$m[1]]; } @@ -420,7 +435,7 @@ protected function findDrupalDirs($cmsRoot, $searchDir) { include "$cmsRoot/sites/sites.php"; } $dirs = array(); - $server = explode('.', implode('.', array_reverse(explode(':', rtrim($this->options['httpHost'], '.'))))); + $server = explode('.', implode('.', array_reverse(explode(':', rtrim($httpHost, '.'))))); for ($j = count($server); $j > 0; $j--) { $s = implode('.', array_slice($server, -$j)); if (isset($sites[$s]) && file_exists("$cmsRoot/sites/" . $sites[$s])) { From 58e974a8456bda52af8dfe554d07cd8f54bdb0a5 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 12 Feb 2025 00:54:01 -0800 Subject: [PATCH 53/90] Add some test-coverage for --url and HTTP_HOST --- tests/Command/EvalCommandTest.php | 52 +++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/Command/EvalCommandTest.php b/tests/Command/EvalCommandTest.php index c5a31dea..ad5142d6 100644 --- a/tests/Command/EvalCommandTest.php +++ b/tests/Command/EvalCommandTest.php @@ -123,4 +123,56 @@ public function testEvalWithCwdOption() { $this->assertMatchesRegularExpression('/^eval says version is [0-9a-z\.]+\s*$/', $p->getOutput()); } + /** + * @param string $level + * @dataProvider getLevels + */ + public function testUrl_cliOptions($level) { + $checkServer = escapeshellarg('printf("HOST=%s HTTPS=%s PORT=%s\n", $_SERVER["HTTP_HOST"] ?? "", $_SERVER["HTTPS"] ?? "", $_SERVER["SERVER_PORT"]??"");'); + + $expect = [ + // Current/recommended param is --url + "ev $checkServer --url='https://u.example-a.com:4321'" => "HOST=u.example-a.com:4321 HTTPS=on PORT=4321", + "ev $checkServer --url='http://u.example-b.com:4321'" => "HOST=u.example-b.com:4321 HTTPS= PORT=4321", + "ev $checkServer --url='http://u.example-c.com'" => "HOST=u.example-c.com HTTPS= PORT=80", + "ev $checkServer --url='https://u.example-d.com'" => "HOST=u.example-d.com HTTPS=on PORT=443", + "ev $checkServer --url='u.example-e.com/subdir'" => "HOST=u.example-e.com HTTPS= PORT=80", + + // For backward compat, accept --hostname + "ev $checkServer --hostname='https://h.example-a.com:4321'" => "HOST=h.example-a.com:4321 HTTPS=on PORT=4321", + "ev $checkServer --hostname='h.example-b.com'" => "HOST=h.example-b.com HTTPS= PORT=80", + + // For backward compat, accept --cms-base-url + "ev $checkServer --cms-base-url='https://c.example-a.com:4321'" => "HOST=c.example-a.com:4321 HTTPS=on PORT=4321", + "ev $checkServer --cms-base-url='c.example-b.com'" => "HOST=c.example-b.com HTTPS= PORT=80", + ]; + + foreach ($expect as $baseCommand => $expectOutput) { + $p1 = Process::runOk($this->cv("$baseCommand --level=$level")); + $this->assertStringContainsString($expectOutput, $p1->getOutput()); + } + } + + /** + * @param string $level + * @dataProvider getLevels + */ + public function testUrl_envVar($level) { + $checkServer = escapeshellarg('printf("HOST=%s HTTPS=%s PORT=%s\n", $_SERVER["HTTP_HOST"] ?? "", $_SERVER["HTTPS"] ?? "", $_SERVER["SERVER_PORT"]??"");'); + + $expect = [ + // string $envVarExpr => string $expectOutput + 'HTTP_HOST=v.example-a.com:123' => "HOST=v.example-a.com:123 HTTPS= PORT=123", + 'HTTP_HOST=v.example-b.com:1234&HTTPS=on' => "HOST=v.example-b.com:1234 HTTPS=on PORT=1234", + 'HTTP_HOST=v.example-c.com' => "HOST=v.example-c.com HTTPS= PORT=80", + 'HTTP_HOST=v.example-d.com&HTTPS=on' => "HOST=v.example-d.com HTTPS=on PORT=443", + ]; + + foreach ($expect as $envVarExpr => $expectOutput) { + parse_str($envVarExpr, $envVars); + $p1 = Process::runOk($this->cv("ev $checkServer --level=$level")->setEnv($envVars)); + $this->assertStringContainsString($expectOutput, $p1->getOutput()); + } + } + } From 3d297257de15717ae50cd60a47d2e58b8e662ca0 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 12 Feb 2025 01:15:42 -0800 Subject: [PATCH 54/90] Bootstrap - If Civi boots CMS, and user doesn't specify hostname, then assume CIVICRM_UF_BASEURL This was already being done Backdrop. An earlier commit in this branch expanded it to WordPress so that `is_ssl()` (etc) would behave more nicely. --- lib/src/Bootstrap.php | 6 ++++-- tests/Command/EvalCommandTest.php | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/lib/src/Bootstrap.php b/lib/src/Bootstrap.php index f4106ed6..9277b9c7 100644 --- a/lib/src/Bootstrap.php +++ b/lib/src/Bootstrap.php @@ -211,8 +211,10 @@ public function boot($options = array()) { throw new \Exception("Could not load the CiviCRM settings file: {$settings}"); } - if (empty($_SERVER['HTTP_HOST']) && in_array($cmsType, ['backdrop', 'wp'])) { - // backdrop_settings_initialize() tries to configure cookie policy - and complains if HTTP_HOST is missing + if (empty($_SERVER['HTTP_HOST'])) { + // 1. backdrop_settings_initialize() tries to configure cookie policy - and complains if HTTP_HOST is missing + // 2. WP functions like plugin_url() and is_ssl() may choose HTTP even if the site supports SSL -- unless you have env setup + // 3. Generally, if there's no indication of what host/scheme to use, then best-guess is UF_BASEURL. $webHostVars = SimulateWeb::convertUrlToCgiVars(defined('CIVICRM_UF_BASEURL') ? CIVICRM_UF_BASEURL : SimulateWeb::localhost()); foreach ($webHostVars as $key => $value) { $_SERVER[$key] = $value; diff --git a/tests/Command/EvalCommandTest.php b/tests/Command/EvalCommandTest.php index ad5142d6..dbbf0e71 100644 --- a/tests/Command/EvalCommandTest.php +++ b/tests/Command/EvalCommandTest.php @@ -175,4 +175,29 @@ public function testUrl_envVar($level) { } } + /** + * If you call 'cv' without any specific URL, then it should tend to look like CIVICRM_UF_BASEURL. + */ + public function testUrl_default() { + foreach (['settings', 'full'] as $level) { + $p1 = Process::runOk($this->cv("ev 'return CIVICRM_UF_BASEURL;'")); + $got = json_decode((string) $p1->getOutput()); + $this->assertMatchesRegularExpression(';^https?://\w+;', $got); + $declaredUrl = parse_url($got); + $expectParts = []; + $expectParts[0] = 'HOST=' . $declaredUrl['host']; + if (!empty($declaredUrl['port'])) { + $expectParts[0] .= ':' . $declaredUrl['port']; + } + $expectParts[1] = 'HTTPS=' . (($declaredUrl['scheme'] ?? NULL) === 'https' ? 'on' : ''); + $expectParts[2] = 'PORT=' . $declaredUrl['port']; + $expectOutput = implode(" ", $expectParts); + + $checkServer = escapeshellarg('printf("HOST=%s HTTPS=%s PORT=%s\n", $_SERVER["HTTP_HOST"] ?? "", $_SERVER["HTTPS"] ?? "", $_SERVER["SERVER_PORT"]??"");'); + $p2 = Process::runOk($this->cv("ev $checkServer --level=$level")); + $this->assertStringContainsString($expectOutput, $p2->getOutput()); + } + + } + } From 20882a0a5c07f4e56df203e46c622630afe66fab Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 12 Feb 2025 13:39:38 -0800 Subject: [PATCH 55/90] For people coming from drush, accept --uri --- lib/src/BaseApplication.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/src/BaseApplication.php b/lib/src/BaseApplication.php index 01d94094..24aa1607 100644 --- a/lib/src/BaseApplication.php +++ b/lib/src/BaseApplication.php @@ -145,12 +145,12 @@ protected function configureIO(InputInterface $input, OutputInterface $output) { protected static function filterDeprecatedOptions(array $argv): array { foreach ($argv as &$arg) { - if (preg_match('/^--(cms-base-url|hostname)$/', $arg, $m)) { - Cv::io()->note(sprintf("Option --%s renamed to --url", $m[1])); + if (preg_match('/^--(cms-base-url|hostname|uri)$/', $arg, $m)) { + Cv::io()->note(sprintf("Option --%s is a deprecated alias for --url (-l)", $m[1])); $arg = '--url'; } - elseif (preg_match('/^--(cms-base-url|hostname)=(.*)/', $arg, $m)) { - Cv::io()->note(sprintf("Option --%s renamed to --url", $m[1])); + elseif (preg_match('/^--(cms-base-url|hostname|uri)=(.*)/', $arg, $m)) { + Cv::io()->note(sprintf("Option --%s is a deprecated alias for --url (-l)", $m[1])); $arg = '--url=' . $m[2]; } } From 085ebb3ae921086e8d50cb0cbe2f466571852943 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 19 Feb 2025 17:54:59 -0800 Subject: [PATCH 56/90] cv status - Report basic info about plugins --- lib/src/CvPlugins.php | 2 +- src/Command/StatusCommand.php | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/src/CvPlugins.php b/lib/src/CvPlugins.php index 0e6ba7c9..a29a2e98 100644 --- a/lib/src/CvPlugins.php +++ b/lib/src/CvPlugins.php @@ -33,7 +33,7 @@ public function init(array $pluginEnv) { } // Always load internal plugins - $this->paths[] = dirname(__DIR__) . '/plugin'; + $this->paths['builtin'] = dirname(__DIR__) . '/plugin'; $this->plugins = []; foreach ($this->paths as $path) { diff --git a/src/Command/StatusCommand.php b/src/Command/StatusCommand.php index 34449d09..2b70b372 100644 --- a/src/Command/StatusCommand.php +++ b/src/Command/StatusCommand.php @@ -3,6 +3,7 @@ namespace Civi\Cv\Command; use Civi\Cv\Application; +use Civi\Cv\Cv; use Civi\Cv\Util\StructuredOutputTrait; use Civi\Test\Invasive; use Symfony\Component\Console\Input\InputInterface; @@ -48,6 +49,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $data['summary'] = $summaryCode; $data['civicrm'] = $this->longCivi($civiCodeVer, $civiDbVer); $data['cv'] = Application::version() . ($isPhar ? ' (phar)' : ' (src)'); + if ($plugins = Cv::plugins()->getPlugins()) { + $data['cv plugins'] = sprintf("%dx (%s)", count($plugins), implode(', ', array_keys($plugins))); + } $data['php'] = $this->longPhp(); $data['mysql'] = $mysqlVersion; $data[$ufType] = $ufVer; @@ -222,6 +226,13 @@ protected function findPathsUrls(OutputInterface $output): array { // Oddballs $urlList['url: CIVICRM_UF_BASEURL'] = \CRM_Utils_Constant::value('CIVICRM_UF_BASEURL'); $pathList['path: extensionsDir'] = \CRM_Core_Config::singleton()->extensionsDir; + if ($output->isVerbose() && $pluginPaths = Cv::plugins()->getPaths()) { + $parts = []; + foreach ($pluginPaths as $key => $pluginPath) { + $parts[] = "[$key] $pluginPath"; + } + $pathList['path: CV_PLUGIN_PATH'] = implode("\n", $parts); + } asort($pathList); asort($urlList); From aff17aa4f4e9f8a439b79b5f0ea6c01435a812b8 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 19 Feb 2025 18:34:28 -0800 Subject: [PATCH 57/90] When printing a table with nested data, use cleaner JSON --- src/Util/StructuredOutputTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Util/StructuredOutputTrait.php b/src/Util/StructuredOutputTrait.php index 66064542..ef275ee0 100644 --- a/src/Util/StructuredOutputTrait.php +++ b/src/Util/StructuredOutputTrait.php @@ -182,7 +182,7 @@ function($value) { return ''; } elseif (is_array($value)) { - return json_encode($value); + return json_encode($value, JSON_UNESCAPED_SLASHES); } elseif (is_object($value)) { return '(' . get_class($value) . ')'; From da150b83c66afe4c677444d011266fcad9f25795 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 19 Feb 2025 17:48:18 -0800 Subject: [PATCH 58/90] Load plugins from cv.phar --- box.json | 1 + lib/src/CvPlugins.php | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/box.json b/box.json index d56ad24a..9e615096 100644 --- a/box.json +++ b/box.json @@ -2,6 +2,7 @@ "chmod": "0755", "directories": [ "lib/src", + "lib/plugin", "src" ], "finder": [ diff --git a/lib/src/CvPlugins.php b/lib/src/CvPlugins.php index a29a2e98..0dc046af 100644 --- a/lib/src/CvPlugins.php +++ b/lib/src/CvPlugins.php @@ -38,7 +38,7 @@ public function init(array $pluginEnv) { $this->plugins = []; foreach ($this->paths as $path) { if (file_exists($path) && is_dir($path)) { - foreach ((array) glob("$path/*.php") as $file) { + foreach ($this->findFiles($path, '/\.php$/') as $file) { $pluginName = preg_replace(';(\d+-)?(.*)(@\w+)?\.php;', '\\2', basename($file)); if ($pluginName === basename($file)) { throw new \RuntimeException("Malformed plugin name: $file"); @@ -91,4 +91,12 @@ public function getPlugins(): array { return $this->plugins; } + private function findFiles(string $path, string $regex): array { + // NOTE: scandir() works better than glob() in PHAR context. + $files = preg_grep($regex, scandir($path)); + return array_map(function ($f) use ($path) { + return "$path/$f"; + }, $files); + } + } From 0eca866b6c2ff3f8db9147f5a486a3be6f726cd0 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 19 Feb 2025 18:27:34 -0800 Subject: [PATCH 59/90] Import lib/plugin/basic-alias --- lib/plugin/basic-alias.php | 189 +++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 lib/plugin/basic-alias.php diff --git a/lib/plugin/basic-alias.php b/lib/plugin/basic-alias.php new file mode 100644 index 00000000..eac45a11 --- /dev/null +++ b/lib/plugin/basic-alias.php @@ -0,0 +1,189 @@ + 1) die("Expect CV_PLUGIN API v1"); + +Cv::dispatcher()->addListener('*.app.site-alias', function(CvEvent $event) { + foreach (AliasFinder::find($event['alias']) as $file) { + $config = AliasFinder::read($file); + ShellAliasHandler::setup($event, $config); + } +}); + +/** + * Find and read alias configurations. + */ +class AliasFinder { + public static function find(string $nameOrWildcard): iterable { + yield from []; + $dirs = array_map('dirname', Cv::plugins()->getPaths()); + foreach ($dirs as $dir) { + foreach (['yaml', 'json'] as $type) { + $pat = "$dir/alias/$nameOrWildcard.$type"; + $files = (array) glob($pat); + foreach ($files as $file) { + yield $file; + } + } + } + } + public static function read(string $file): array { + if (preg_match(';\.ya?ml$;', $file)) { + if (!is_callable('yaml_parse')) { + throw new \RuntimeException("Cannot load $file. Missing yaml_parse()."); + } + $parsed = yaml_parse(file_get_contents($file)); + } + elseif (preg_match(';\.json$;', $file)) { + $parsed = json_decode(file_get_contents($file), 1); + } + else { + throw new \RuntimeException("Unrecognized alias file type: $file"); + } + + if (empty($parsed) || !is_array($parsed)) { + throw new \RuntimeException("Alias file ($file) appears invalid"); + } + return $parsed; + } +} + +class ShellAliasHandler { + + /** + * Read the configuration options from JSON/YAML. Update the $event['transport']. + */ + public static function setup(CvEvent $event, array $config): void { + /** @var \Civi\Cv\Util\CvArgvInput $input */ + $input = $event['input']; + /** @var \CvDeps\Symfony\Component\Console\Output\OutputInterface $output */ + $output = $event['output']; + $isRemote = !empty($config['remote_command']); + $localCvBin = $input->getOriginalArgv()[0]; + + $defaultConfig = [ + 'env' => [], + 'options' => [], + 'cv_command' => $isRemote ? 'cv' : $localCvBin, + ]; + $config = array_merge($defaultConfig, $config); + + $cvCommand = array_merge(static::cvCommand($config), static::passthruArgs($input->getOriginalArgv())); + if ($isRemote) { + $fullCommand = $config['remote_command'] . ' bash -c ' . escapeshellarg(implode(' ', $cvCommand)); + } + else { + $fullCommand = '( ' . implode(' ', $cvCommand) . ' )'; + } + + $event['transport'] = function() use ($input, $output, $fullCommand) { + if ($output->isVeryVerbose()) { + $output->write('Found alias. Run subcommand: '); + $output->writeln($fullCommand, OutputInterface::OUTPUT_RAW); + } + // echo "TODO call passthru\n"; + static::passthru($fullCommand); + }; + } + + public static function cvCommand(array $config): array { + $result = []; + if (!empty($config['env'])) { + foreach ($config['env'] as $key => $value) { + $result[] = "$key=" . escapeString($value); + } + // This technique allows things like " + $result[] = sprintf('; export %s;', implode(' ', array_keys($config['env']))); + } + + $result[] = $config['cv_command']; + foreach ($config['options'] ?? [] as $key => $value) { + if ($value === NULL) { + $result[] = escapeString("--$key"); + } + else { + $result[] = escapeString("--$key=$value"); + } + } + + return $result; + } + + /** + * Figure out which arguments to pass-thru to subcommand. + * + * @param array $rawArgs + * @return array + */ + public static function passthruArgs(array $rawArgs): array { + array_shift($rawArgs); /* ignore program name */ + + $result = []; + while (count($rawArgs)) { + $rawArg = array_shift($rawArgs); + if ($rawArg === '--site-alias') { + // Ignore next part + array_shift($rawArgs); + } + elseif (strpos($rawArg, '--site-alias=') === 0) { + // ignore + } + else { + $result[] = escapeString($rawArg); + } + } + return $result; + } + + public static function passthru(string $command): int { + $process = proc_open( + $command, + [0 => STDIN, 1 => STDOUT, 2 => STDERR], + $pipes + ); + return proc_close($process); + } +} + + + +function escapeString(string $expr): string { + return preg_match('{^[\w=-]+$}', $expr) ? $expr : escapeshellarg($expr); +} From bb2d20eb0aa711ae2a371faae151da63468a19f1 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 19 Feb 2025 18:28:32 -0800 Subject: [PATCH 60/90] (NFC) basic-alias.php --- lib/plugin/basic-alias.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/plugin/basic-alias.php b/lib/plugin/basic-alias.php index eac45a11..797b5b01 100644 --- a/lib/plugin/basic-alias.php +++ b/lib/plugin/basic-alias.php @@ -38,7 +38,9 @@ use Civi\Cv\CvEvent; use CvDeps\Symfony\Component\Console\Output\OutputInterface; -if (empty($CV_PLUGIN['protocol']) || $CV_PLUGIN['protocol'] > 1) die("Expect CV_PLUGIN API v1"); +if (empty($CV_PLUGIN['protocol']) || $CV_PLUGIN['protocol'] > 1) { + die("Expect CV_PLUGIN API v1"); +} Cv::dispatcher()->addListener('*.app.site-alias', function(CvEvent $event) { foreach (AliasFinder::find($event['alias']) as $file) { @@ -51,6 +53,7 @@ * Find and read alias configurations. */ class AliasFinder { + public static function find(string $nameOrWildcard): iterable { yield from []; $dirs = array_map('dirname', Cv::plugins()->getPaths()); @@ -64,6 +67,7 @@ public static function find(string $nameOrWildcard): iterable { } } } + public static function read(string $file): array { if (preg_match(';\.ya?ml$;', $file)) { if (!is_callable('yaml_parse')) { @@ -83,6 +87,7 @@ public static function read(string $file): array { } return $parsed; } + } class ShellAliasHandler { @@ -180,9 +185,8 @@ public static function passthru(string $command): int { ); return proc_close($process); } -} - +} function escapeString(string $expr): string { return preg_match('{^[\w=-]+$}', $expr) ? $expr : escapeshellarg($expr); From e66ac688033c313c805c0bea4321c5855e4986b0 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Thu, 20 Feb 2025 02:43:47 -0800 Subject: [PATCH 61/90] Add lib/plugin/alias-cmd for managing aliases --- lib/plugin/alias-cmd.php | 328 +++++++++++++++++++++++++++++++++++++ lib/plugin/basic-alias.php | 27 ++- 2 files changed, 352 insertions(+), 3 deletions(-) create mode 100644 lib/plugin/alias-cmd.php diff --git a/lib/plugin/alias-cmd.php b/lib/plugin/alias-cmd.php new file mode 100644 index 00000000..0af60873 --- /dev/null +++ b/lib/plugin/alias-cmd.php @@ -0,0 +1,328 @@ + 1) { + die("Expect CV_PLUGIN API v1"); +} + +Cv::dispatcher()->addListener('cv.app.commands', function($e) { + $e['commands'][] = new AliasListCommand(); + $e['commands'][] = new AliasAddCommand(); +}); + +class AliasListCommand extends CvCommand { + use StructuredOutputTrait; + + protected function configure() { + $this + ->setName('alias:list') + ->setDescription('List any @aliases') + ->configureOutputOptions(['tabular' => TRUE, 'fallback' => 'table', 'defaultColumns' => 'name,type,config', 'shortcuts' => TRUE]) + ->setBootOptions(['auto' => FALSE]); + } + + protected function execute($input, $output): int { + $aliasEvent = Cv::filter(Cv::app()->getName() . ".app.site-alias.list", [ + 'aliases' => [], + ]); + $aliases = array_map(function($alias) { + return [ + 'name' => $alias['name'], + 'type' => $alias['type'], + 'config' => $alias['config'], + ]; + }, $aliasEvent['aliases']); + $this->sendStandardTable($aliases); + return 0; + } + +} + +class AliasAddCommand extends CvCommand { + + protected function configure() { + $this + ->setName('alias:add') + ->addArgument('name', InputArgument::OPTIONAL, 'Alias name') + ->addArgument('path', InputArgument::OPTIONAL, 'Local path to the instance (web-root)') + ->setDescription('Interactively create a new @alias') + ->setBootOptions(['auto' => FALSE]); + } + + protected function execute($input, $output): int { + if (!class_exists(AliasFinder::class)) { + throw new \Exception("Cannot add new aliases without the \"basic-alias\" plugin."); + } + + $answers['name'] = $this->askName(); + $answers['path'] = $this->askPath(); + $answers['mode'] = $this->askBootstrap($answers['path']); + $answers['settings'] = ($answers['mode'] === 'settings') ? $this->askSettings($answers['path']) : NULL; + if ($answers['mode'] !== 'settings' && $this->askMultisite($answers['path'])) { + $answers['url'] = $this->askUrl($answers['name'], $answers['path']); + } + $answers['user'] = $this->askUser($answers['name']); + + $this->writeInfo($this->askAliasFile($answers['name'] . '.json'), $this->createInfo($answers)); + return 0; + } + + protected function writeInfo(string $outputFile, array $info): void { + $infoJson = json_encode($info, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n"; + + $parent = dirname($outputFile); + if (!is_dir($parent)) { + mkdir($parent, 0777, TRUE); + } + file_put_contents($outputFile, $infoJson); + } + + protected function createInfo(array $answers) { + extract($answers); + + $info = []; + $modeMap = [ + 'auto' => 'Auto://', + 'standalone' => 'Standalone://', + 'backdrop' => 'Backdrop://', + 'drupal' => 'Drupal8://', + 'drupal7' => 'Drupal://', + 'joomla' => 'Joomla://', + 'wordpress' => 'WordPress://', + ]; + if (isset($modeMap[$answers['mode']])) { + $info['env']['CIVICRM_BOOT'] = $modeMap[$answers['mode']] . ltrim($answers['path'], '/' . DIRECTORY_SEPARATOR); + } + elseif ($answers['mode'] === 'settings') { + $info['env']['CIVICRM_SETTINGS'] = $answers['settings']; + $info['options']['cwd'] = $answers['path']; + } + + if (!empty($answers['url'])) { + $info['options']['url'] = $answers['url']; + } + if (!empty($answers['user'])) { + $info['options']['user'] = $answers['user']; + } + return $info; + } + + protected function askAliasFile(string $entry): string { + Cv::io()->section('Save alias info'); + + $collections['all'] = AliasFinder::getFolders(); + $collections['extant'] = array_filter($collections['all'], function($d) { + return is_dir($d) && is_writable($d); + }); + $collections['viable'] = array_values(array_filter($collections['all'], function($d) { + $iter = $d; + while (dirname($iter) && dirname($iter) !== $iter) { + if (is_dir($iter) && is_writable($iter)) { + return TRUE; + } + $iter = dirname($iter); + } + return FALSE; + })); + + foreach (['extant', 'viable'] as $type) { + $options = array_values($collections[$type]); + foreach ($options as $option) { + if (file_exists("$option/$entry")) { + Cv::io()->info("Found existing alias entry: $option/$entry"); + if (!Cv::io()->confirm('Overwrite existing file?')) { + throw new \Exception("Aborted. File already exists."); + } + return "$option/$entry"; + } + } + if (count($options) === 1) { + Cv::io()->note("Found existing alias folder ({$options[0]})"); + return $options[0] . "/$entry"; + } + if (count($options) > 1) { + return Cv::io()->choice('Where should we store the alias entry?', $options) . "/$entry"; + } + } + throw new \RuntimeException(sprintf("Failed to identify a viable folder for aliases. All candidates (%s) are unwritable.", implode(' ', $collections['all']))); + } + + protected function askName(): string { + $validateName = function ($name): ?string { + $name = trim($name); + if (empty($name)) { + throw new \Exception("The alias name is required"); + } + if (!preg_match('/^[a-zA-Z0-9\-]+$/', $name)) { + throw new \Exception("Malformed alias ($name). Use only alphanumerics and dashes."); + } + return $name; + }; + + Cv::io()->title('Site Aliases: Add new'); + if ($name = Cv::input()->getArgument('name')) { + $name = $validateName($name); + Cv::io()->writeln("Alias-name: $name"); + } + else { + Cv::io()->section('Configure alias-name'); + Cv::io()->info([ + 'The alias is a short nickname to identify your CiviCRM instance. It allows you to construct shorter commands.', + 'For example, if you choose the alias "wombatcrm", then you can construct commands like:', + '$ cv @wombatcrm status', + ]); + $name = Cv::io()->ask('Alias-name (required)', NULL, $validateName); + } + return $name; + } + + protected function askPath(): string { + $validatePath = function ($path): ?string { + $path = trim($path); + $path = rtrim($path, '/' . DIRECTORY_SEPARATOR); + if (!is_dir($path)) { + throw new \Exception("The path ($path) is not valid."); + } + return $path; + }; + + if ($path = Cv::input()->getArgument('path')) { + $path = $validatePath($path); + Cv::io()->writeln("Root-path: $path"); + } + else { + Cv::io()->section('Configure root-path'); + Cv::io()->info('The root-path identifies the source-tree (web-root) that includes CiviCRM.'); + $path = Cv::io()->ask('Root-path (required)', getcwd(), $validatePath); + } + return $path; + } + + protected function askBootstrap(string $path): string { + Cv::io()->section('Configure bootstrap mode'); + Cv::io()->info([ + "CiviCRM may run as a standalone application or as an add-on with another application (such as Drupal or WordPress).", + "cv needs to determine which kind of application lives in $path.", + "This can often be done automatically. However, some systems work better with manual options. For example, if you have created symlinks, or if you have rearranged the folders in WordPress, then use manual.", + ]); + $choice = Cv::io()->choice('Bootstrap mode', [ + 'auto' => 'Identify the system automatically (using file-layout)', + 'manual' => 'Manually specify the system type.', + 'settings' => 'Identify the system by reading "civicrm.settings.php" (legacy)', + // The 'auto' and 'manual' options are more representative of HTTP lifecycle, and they can preserve current CWD. + // However, 'settings' is closer to the actual default behavior. + ], 'auto'); + + if ($choice !== 'manual') { + return $choice; + } + else { + return Cv::io()->choice('Application type', [ + 'standalone' => 'CiviCRM-Standalone', + 'backdrop' => 'Backdrop with CiviCRM', + 'drupal' => 'Drupal (8/9/10/11) with CiviCRM', + 'drupal7' => 'Drupal (7) with CiviCRM', + 'joomla' => 'Joomla with CiviCRM', + 'wordpress' => 'WordPress with CiviCRM', + ]); + } + } + + protected function askMultisite(string $path): bool { + Cv::io()->section('Configure multi-site options?'); + Cv::io()->info([ + 'In single-site installations, CiviCRM has one codebase, one database, and one URL.', + 'In multi-site installations, the codebase may be re-used with multiple databases and/or multiple URLs. This may require extra options.', + ]); + return Cv::io()->confirm("Does $path require multi-site options?", FALSE); + } + + protected function askSettings(string $path): string { + $validateSettings = function ($settings): ?string { + $settings = trim($settings); + if (empty($settings)) { + throw new \Exception("The settings path is required"); + } + if (!file_exists($settings) || !is_readable($settings)) { + throw new \Exception("The settings path ($settings) is not valid."); + } + return $settings; + }; + + Cv::io()->section("Configure settings path"); + + Cv::io()->info("To use this bootstrap option, we must choose a settings file. Let's search for some candidates."); + Cv::io()->writeln('Searching...'); + + $settingsFiles = []; + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path)); + foreach ($iterator as $file) { + if ($file->getFilename() === 'civicrm.settings.php') { + $settingsFiles[] = $file->getPathname(); + } + } + switch (count($settingsFiles)) { + case 0: + throw new \Exception(sprintf('In "%s", we could not find any files named "%s". This may indicate an incorrect path or other misconfiguration.', $path, 'civicrm.settings.php')); + + case 1: + Cv::io()->info('We found 1 file which appears to be suitable:'); + Cv::io()->listing($settingsFiles); + return $settingsFiles[0]; + + default: + sort($settingsFiles); + Cv::io()->info('We found ' . count($settingsFiles) . ' files which appear to be suitable:'); + Cv::io()->listing($settingsFiles); + return Cv::io()->ask('Settings file', NULL, $validateSettings); + } + } + + protected function askUrl(string $name, string $path): string { + $validateUrl = function($url): ?string { + $url = trim($url); + if (empty($url)) { + throw new \Exception("The URL is required for multi-site mode."); + } + + $parsed = parse_url($url); + if (empty($parsed['scheme']) || empty($parsed['host'])) { + throw new \Exception("The URL must specify a scheme and hostname, such as \"https://example.com\""); + } + if (!in_array($parsed['scheme'], ['http', 'https'])) { + throw new \Exception("The only supported URL schemes are \"http\" and \"https\""); + } + return $url; + }; + Cv::io()->section('Configure multi-site options: Web URL'); + Cv::io()->info([ + "Each CiviCRM instance can be identified by its web URL. Which URL should be associated with \"@{$name}\"?", + "Example: https://sub-site-123.example.com/", + ]); + return Cv::io()->ask('Web URL', NULL, $validateUrl); + } + + protected function askUser(string $name): ?string { + Cv::io()->section('Configure default user (OPTIONAL)'); + Cv::io()->info([ + "In the CiviCRM CLI, -most- commands execute as the system (with super-privileges).", + "However, -some- CLI commands may involve a CiviCRM user.", + "To handle these commands automatically, specify the default user for \"@{$name}\".", + ]); + return trim(Cv::io()->ask('Default user (optional)')); + } + +} diff --git a/lib/plugin/basic-alias.php b/lib/plugin/basic-alias.php index 797b5b01..a558b0fd 100644 --- a/lib/plugin/basic-alias.php +++ b/lib/plugin/basic-alias.php @@ -49,6 +49,20 @@ } }); +Cv::dispatcher()->addListener('*.app.site-alias.list', function(CvEvent $event) { + foreach (AliasFinder::find('*') as $file) { + $name = preg_replace('/\.(json|yaml)/', '', basename($file)); + $event['aliases'][] = [ + 'name' => $name, + 'type' => 'basic', + 'config' => $file, + 'getter' => function () use ($file) { + return AliasFinder::read($file); + }, + ]; + } +}); + /** * Find and read alias configurations. */ @@ -56,10 +70,9 @@ class AliasFinder { public static function find(string $nameOrWildcard): iterable { yield from []; - $dirs = array_map('dirname', Cv::plugins()->getPaths()); - foreach ($dirs as $dir) { + foreach (static::getFolders() as $dir) { foreach (['yaml', 'json'] as $type) { - $pat = "$dir/alias/$nameOrWildcard.$type"; + $pat = "$dir/$nameOrWildcard.$type"; $files = (array) glob($pat); foreach ($files as $file) { yield $file; @@ -68,6 +81,14 @@ public static function find(string $nameOrWildcard): iterable { } } + public static function getFolders(): array { + $dirs = []; + foreach (Cv::plugins()->getPaths() as $pluginDir) { + $dirs[] = dirname($pluginDir) . '/alias'; + } + return $dirs; + } + public static function read(string $file): array { if (preg_match(';\.ya?ml$;', $file)) { if (!is_callable('yaml_parse')) { From 592042f71e51d682651bf2967fc1adb7c23d612a Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 26 Feb 2025 01:31:10 -0800 Subject: [PATCH 62/90] alias-cmd.php - Tweak messaging --- lib/plugin/alias-cmd.php | 71 ++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/lib/plugin/alias-cmd.php b/lib/plugin/alias-cmd.php index 0af60873..5e0ab925 100644 --- a/lib/plugin/alias-cmd.php +++ b/lib/plugin/alias-cmd.php @@ -13,6 +13,7 @@ use Civi\Cv\Cv; use Civi\Cv\Util\StructuredOutputTrait; use CvDeps\Symfony\Component\Console\Input\InputArgument; +use CvDeps\Symfony\Component\Console\Output\OutputInterface; if (empty($CV_PLUGIN['protocol']) || $CV_PLUGIN['protocol'] > 1) { die("Expect CV_PLUGIN API v1"); @@ -66,6 +67,7 @@ protected function execute($input, $output): int { if (!class_exists(AliasFinder::class)) { throw new \Exception("Cannot add new aliases without the \"basic-alias\" plugin."); } + Cv::io()->title('Site Aliases: Add new'); $answers['name'] = $this->askName(); $answers['path'] = $this->askPath(); @@ -76,12 +78,22 @@ protected function execute($input, $output): int { } $answers['user'] = $this->askUser($answers['name']); - $this->writeInfo($this->askAliasFile($answers['name'] . '.json'), $this->createInfo($answers)); + Cv::io()->section('Generate configuration'); + $configJson = $this->createInfo($answers); + Cv::io()->writeln("This is the configuration for your alias:\n"); + Cv::io()->writeln($configJson, OutputInterface::OUTPUT_PLAIN); + + $this->writeInfo($this->askAliasFile($answers['name'] . '.json'), $configJson); + + Cv::io()->success([ + "Successfully added alias \"@{$answers['name']}\".", + "You may now run commands like \"cv @{$answers['name']} status\" ", + ]); return 0; } - protected function writeInfo(string $outputFile, array $info): void { - $infoJson = json_encode($info, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n"; + protected function writeInfo(string $outputFile, string $infoJson): void { + Cv::io()->section('Write ' . $outputFile); $parent = dirname($outputFile); if (!is_dir($parent)) { @@ -90,7 +102,7 @@ protected function writeInfo(string $outputFile, array $info): void { file_put_contents($outputFile, $infoJson); } - protected function createInfo(array $answers) { + protected function createInfo(array $answers): string { extract($answers); $info = []; @@ -117,11 +129,12 @@ protected function createInfo(array $answers) { if (!empty($answers['user'])) { $info['options']['user'] = $answers['user']; } - return $info; + + return json_encode($info, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n"; } protected function askAliasFile(string $entry): string { - Cv::io()->section('Save alias info'); + Cv::io()->section('Identify alias storage'); $collections['all'] = AliasFinder::getFolders(); $collections['extant'] = array_filter($collections['all'], function($d) { @@ -142,15 +155,15 @@ protected function askAliasFile(string $entry): string { $options = array_values($collections[$type]); foreach ($options as $option) { if (file_exists("$option/$entry")) { - Cv::io()->info("Found existing alias entry: $option/$entry"); - if (!Cv::io()->confirm('Overwrite existing file?')) { + Cv::io()->info("Found existing file: $option/$entry"); + if (!Cv::io()->confirm('Overwrite file?')) { throw new \Exception("Aborted. File already exists."); } return "$option/$entry"; } } if (count($options) === 1) { - Cv::io()->note("Found existing alias folder ({$options[0]})"); + Cv::io()->info("Found existing alias folder ({$options[0]})"); return $options[0] . "/$entry"; } if (count($options) > 1) { @@ -172,7 +185,6 @@ protected function askName(): string { return $name; }; - Cv::io()->title('Site Aliases: Add new'); if ($name = Cv::input()->getArgument('name')) { $name = $validateName($name); Cv::io()->writeln("Alias-name: $name"); @@ -180,9 +192,9 @@ protected function askName(): string { else { Cv::io()->section('Configure alias-name'); Cv::io()->info([ - 'The alias is a short nickname to identify your CiviCRM instance. It allows you to construct shorter commands.', - 'For example, if you choose the alias "wombatcrm", then you can construct commands like:', - '$ cv @wombatcrm status', + 'The alias is a brief nickname to identify your CiviCRM instance. It allows you to construct shorter commands.', + 'For example, if you choose the alias "wombat", then you can construct commands like:', + '$ cv @wombat status', ]); $name = Cv::io()->ask('Alias-name (required)', NULL, $validateName); } @@ -212,16 +224,16 @@ protected function askPath(): string { } protected function askBootstrap(string $path): string { - Cv::io()->section('Configure bootstrap mode'); + Cv::io()->section('Configure application type'); Cv::io()->info([ - "CiviCRM may run as a standalone application or as an add-on with another application (such as Drupal or WordPress).", + "CiviCRM may run as a standalone application or as an add-on (alongside Drupal, WordPress, or similar).", "cv needs to determine which kind of application lives in $path.", - "This can often be done automatically. However, some systems work better with manual options. For example, if you have created symlinks, or if you have rearranged the folders in WordPress, then use manual.", + "This can often be done automatically. However, some systems work better with manual options. For example, if you have created symlinks, or if you have reorganized the default folders, then use manual.", ]); - $choice = Cv::io()->choice('Bootstrap mode', [ - 'auto' => 'Identify the system automatically (using file-layout)', - 'manual' => 'Manually specify the system type.', - 'settings' => 'Identify the system by reading "civicrm.settings.php" (legacy)', + $choice = Cv::io()->choice('Application type', [ + 'auto' => 'Identify the application automatically (using file-layout)', + 'manual' => 'Manually specify the application.', + 'settings' => 'Identify the application by reading "civicrm.settings.php" (legacy)', // The 'auto' and 'manual' options are more representative of HTTP lifecycle, and they can preserve current CWD. // However, 'settings' is closer to the actual default behavior. ], 'auto'); @@ -230,7 +242,7 @@ protected function askBootstrap(string $path): string { return $choice; } else { - return Cv::io()->choice('Application type', [ + return Cv::io()->choice('Application type (manual)', [ 'standalone' => 'CiviCRM-Standalone', 'backdrop' => 'Backdrop with CiviCRM', 'drupal' => 'Drupal (8/9/10/11) with CiviCRM', @@ -244,10 +256,11 @@ protected function askBootstrap(string $path): string { protected function askMultisite(string $path): bool { Cv::io()->section('Configure multi-site options?'); Cv::io()->info([ - 'In single-site installations, CiviCRM has one codebase, one database, and one URL.', - 'In multi-site installations, the codebase may be re-used with multiple databases and/or multiple URLs. This may require extra options.', + // 'In single-site installations, CiviCRM has one codebase, one database, and one URL.', + 'In multi-site installations, the codebase is shared by multiple URLs and/or multiple databases.', + 'If your system uses multi-site, then we should configure additional options.', ]); - return Cv::io()->confirm("Does $path require multi-site options?", FALSE); + return Cv::io()->confirm("Enable multi-site options?", FALSE); } protected function askSettings(string $path): string { @@ -316,13 +329,13 @@ protected function askUrl(string $name, string $path): string { } protected function askUser(string $name): ?string { - Cv::io()->section('Configure default user (OPTIONAL)'); + Cv::io()->section('Configure default username (optional)'); Cv::io()->info([ - "In the CiviCRM CLI, -most- commands execute as the system (with super-privileges).", - "However, -some- CLI commands may involve a CiviCRM user.", - "To handle these commands automatically, specify the default user for \"@{$name}\".", + "Most cv subcommands execute with super-privileges, but some require a user.", + "If you have an existing CiviCRM user-account, you may use it by default.", ]); - return trim(Cv::io()->ask('Default user (optional)')); + $user = Cv::io()->ask('Default username (optional)'); + return $user ? trim($user) : $user; } } From a743a7148c5e909400d0a20fd8f4ae0b182787e2 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 26 Feb 2025 13:27:11 -0800 Subject: [PATCH 63/90] alias-cmd - Add questions for remote access. Tweak other messaging. --- lib/plugin/alias-cmd.php | 91 +++++++++++++++++++++++++++++++++------- 1 file changed, 75 insertions(+), 16 deletions(-) diff --git a/lib/plugin/alias-cmd.php b/lib/plugin/alias-cmd.php index 5e0ab925..e05aadc5 100644 --- a/lib/plugin/alias-cmd.php +++ b/lib/plugin/alias-cmd.php @@ -58,7 +58,7 @@ protected function configure() { $this ->setName('alias:add') ->addArgument('name', InputArgument::OPTIONAL, 'Alias name') - ->addArgument('path', InputArgument::OPTIONAL, 'Local path to the instance (web-root)') + ->addArgument('local-path', InputArgument::OPTIONAL, 'Local path to the instance (web-root)') ->setDescription('Interactively create a new @alias') ->setBootOptions(['auto' => FALSE]); } @@ -70,7 +70,14 @@ protected function execute($input, $output): int { Cv::io()->title('Site Aliases: Add new'); $answers['name'] = $this->askName(); - $answers['path'] = $this->askPath(); + if (empty($input->getArgument('local-path'))) { + $answers['remote_command'] = $this->askRemoteCommand(); + if ($answers['remote_command']) { + $answers['cv_command'] = $this->askCvCommand(); + // NOTE: We don't need cv_command locally -- because clearly the user is already able to run 'cv alias:add'. + } + } + $answers['path'] = $this->askPath(empty($answers['remote_command'])); $answers['mode'] = $this->askBootstrap($answers['path']); $answers['settings'] = ($answers['mode'] === 'settings') ? $this->askSettings($answers['path']) : NULL; if ($answers['mode'] !== 'settings' && $this->askMultisite($answers['path'])) { @@ -86,7 +93,7 @@ protected function execute($input, $output): int { $this->writeInfo($this->askAliasFile($answers['name'] . '.json'), $configJson); Cv::io()->success([ - "Successfully added alias \"@{$answers['name']}\".", + "Created alias \"@{$answers['name']}\".", "You may now run commands like \"cv @{$answers['name']} status\" ", ]); return 0; @@ -106,6 +113,15 @@ protected function createInfo(array $answers): string { extract($answers); $info = []; + + if (!empty($answers['remote_command'])) { + $info['remote_command'] = $answers['remote_command']; + $info['options']['cwd'] = $answers['path']; + } + if (!empty($answers['cv_command'])) { + $info['cv_command'] = $answers['cv_command']; + } + $modeMap = [ 'auto' => 'Auto://', 'standalone' => 'Standalone://', @@ -163,7 +179,7 @@ protected function askAliasFile(string $entry): string { } } if (count($options) === 1) { - Cv::io()->info("Found existing alias folder ({$options[0]})"); + Cv::io()->info("Alias folder is {$options[0]}"); return $options[0] . "/$entry"; } if (count($options) > 1) { @@ -175,10 +191,10 @@ protected function askAliasFile(string $entry): string { protected function askName(): string { $validateName = function ($name): ?string { - $name = trim($name); if (empty($name)) { throw new \Exception("The alias name is required"); } + $name = trim($name); if (!preg_match('/^[a-zA-Z0-9\-]+$/', $name)) { throw new \Exception("Malformed alias ($name). Use only alphanumerics and dashes."); } @@ -193,25 +209,24 @@ protected function askName(): string { Cv::io()->section('Configure alias-name'); Cv::io()->info([ 'The alias is a brief nickname to identify your CiviCRM instance. It allows you to construct shorter commands.', - 'For example, if you choose the alias "wombat", then you can construct commands like:', - '$ cv @wombat status', + "Example: Run a command with alias \"wombat\"\n$ cv @wombat status", ]); $name = Cv::io()->ask('Alias-name (required)', NULL, $validateName); } return $name; } - protected function askPath(): string { - $validatePath = function ($path): ?string { + protected function askPath(bool $isLocal): string { + $validatePath = function ($path) use ($isLocal): ?string { $path = trim($path); $path = rtrim($path, '/' . DIRECTORY_SEPARATOR); - if (!is_dir($path)) { + if ($isLocal && !is_dir($path)) { throw new \Exception("The path ($path) is not valid."); } return $path; }; - if ($path = Cv::input()->getArgument('path')) { + if ($path = Cv::input()->getArgument('local-path')) { $path = $validatePath($path); Cv::io()->writeln("Root-path: $path"); } @@ -223,15 +238,59 @@ protected function askPath(): string { return $path; } + protected function askRemoteCommand(): ?string { + $validateCommand = function ($command): ?string { + if (is_string($command)) { + $command = trim($command); + // The examples use '$ ' prefix, which may easily be misinterpreted by reader as literal. + if (substr($command, 0, 2) === '$ ') { + $command = substr($command, 2); + } + } + if (empty($command)) { + throw new \Exception("The command is required for remote access."); + } + return $command; + }; + + Cv::io()->section('Configure local/remote access'); + Cv::io()->info([ + 'cv can access an instance of CiviCRM on the local host -- or on a remote (SSH) server.', + ]); + $isRemote = Cv::io()->choice('Where is CiviCRM running?', [ + 'local' => 'Local CiviCRM site', + 'remote' => 'Remote CiviCRM site (SSH)', + ], 'local'); + if ($isRemote === 'local') { + return NULL; + } + Cv::io()->info([ + 'Please describe a command to connect to the remote server. Here are a few examples.', + "Connect via SSH to server.example.com:\n$ ssh server.example.com", + "Connect via SSH to server.example.com as user www-data:\n$ ssh www-data@server.example.com", + "Connect via SSH to server.example.com on port 2222:\n$ ssh -p 2222 server.example.com", + ]); + return Cv::io()->ask('Remote access command (required)', NULL, $validateCommand); + } + + protected function askCvCommand(): ?string { + // Cv::io()->section('Configure remote cv command? (optional)'); + Cv::io()->info([ + 'The remote server must have its own copy of cv. If this has been installed in a standard PATH (such as /usr/local/bin), then no extra work is required.', + 'If the remote copy of cv lives in a custom location (as /var/www/mysite/vendor/bin), then specify it.', + ]); + return Cv::io()->ask('Remote cv command (optional)'); + } + protected function askBootstrap(string $path): string { Cv::io()->section('Configure application type'); Cv::io()->info([ "CiviCRM may run as a standalone application or as an add-on (alongside Drupal, WordPress, or similar).", - "cv needs to determine which kind of application lives in $path.", - "This can often be done automatically. However, some systems work better with manual options. For example, if you have created symlinks, or if you have reorganized the default folders, then use manual.", + "cv can usually identify the application automatically by examining the file-layout.", + "However, if you have customized the file-layout (symlinks or path-overrides), then it may need extra hints.", ]); $choice = Cv::io()->choice('Application type', [ - 'auto' => 'Identify the application automatically (using file-layout)', + 'auto' => 'Identify the application automatically (examine file-layout)', 'manual' => 'Manually specify the application.', 'settings' => 'Identify the application by reading "civicrm.settings.php" (legacy)', // The 'auto' and 'manual' options are more representative of HTTP lifecycle, and they can preserve current CWD. @@ -254,7 +313,7 @@ protected function askBootstrap(string $path): string { } protected function askMultisite(string $path): bool { - Cv::io()->section('Configure multi-site options?'); + Cv::io()->section('Configure multi-site options'); Cv::io()->info([ // 'In single-site installations, CiviCRM has one codebase, one database, and one URL.', 'In multi-site installations, the codebase is shared by multiple URLs and/or multiple databases.', @@ -329,7 +388,7 @@ protected function askUrl(string $name, string $path): string { } protected function askUser(string $name): ?string { - Cv::io()->section('Configure default username (optional)'); + Cv::io()->section('Configure default username'); Cv::io()->info([ "Most cv subcommands execute with super-privileges, but some require a user.", "If you have an existing CiviCRM user-account, you may use it by default.", From 61d7e5d251987de4d86eb1d86aa55f9f87d90594 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 26 Feb 2025 13:40:07 -0800 Subject: [PATCH 64/90] (REF) Extract isCreateable() --- lib/plugin/alias-cmd.php | 10 ++-------- lib/src/Util/Filesystem.php | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/lib/plugin/alias-cmd.php b/lib/plugin/alias-cmd.php index e05aadc5..8b843dfc 100644 --- a/lib/plugin/alias-cmd.php +++ b/lib/plugin/alias-cmd.php @@ -11,6 +11,7 @@ use Civi\Cv\Command\CvCommand; use Civi\Cv\Cv; +use Civi\Cv\Util\Filesystem; use Civi\Cv\Util\StructuredOutputTrait; use CvDeps\Symfony\Component\Console\Input\InputArgument; use CvDeps\Symfony\Component\Console\Output\OutputInterface; @@ -157,14 +158,7 @@ protected function askAliasFile(string $entry): string { return is_dir($d) && is_writable($d); }); $collections['viable'] = array_values(array_filter($collections['all'], function($d) { - $iter = $d; - while (dirname($iter) && dirname($iter) !== $iter) { - if (is_dir($iter) && is_writable($iter)) { - return TRUE; - } - $iter = dirname($iter); - } - return FALSE; + return Filesystem::isCreatable($d); })); foreach (['extant', 'viable'] as $type) { diff --git a/lib/src/Util/Filesystem.php b/lib/src/Util/Filesystem.php index 19719615..44dbcef2 100644 --- a/lib/src/Util/Filesystem.php +++ b/lib/src/Util/Filesystem.php @@ -9,6 +9,32 @@ public static function exists(?string $path): bool { return $path !== NULL && file_exists($path); } + /** + * Determine whether the given $path can be created. + * + * It does not matter if the parent exists already (if the parent is creatable). + * + * It only matters if we have sufficient write access to some ancestor. + * + * @param string $path + * The file that you would like to create. + * @return bool + */ + public static function isCreatable(string $path): bool { + if (file_exists($path)) { + return is_writable($path); + } + + $iter = $path; + while (!empty($iter) && dirname($iter) !== $iter) { + if (file_exists($iter)) { + return is_dir($iter) && is_writable($iter); + } + $iter = dirname($iter); + } + return FALSE; + } + /** * @return false|string */ From 6cd1e37a6814de91677049c6f4567028a606802a Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 26 Feb 2025 13:42:41 -0800 Subject: [PATCH 65/90] basic-alias - Simpler search-path It's a little bit magical to link CV_PLUGIN_PATH to the aliases-list. We can reassess whether this should be more magical when someone actually has a use-case. --- lib/plugin/basic-alias.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/plugin/basic-alias.php b/lib/plugin/basic-alias.php index a558b0fd..231002e0 100644 --- a/lib/plugin/basic-alias.php +++ b/lib/plugin/basic-alias.php @@ -82,9 +82,9 @@ public static function find(string $nameOrWildcard): iterable { } public static function getFolders(): array { - $dirs = []; - foreach (Cv::plugins()->getPaths() as $pluginDir) { - $dirs[] = dirname($pluginDir) . '/alias'; + $dirs = ['/etc/cv/alias', '/usr/local/share/cv/alias', '/usr/share/cv/alias']; + if (getenv('HOME')) { + array_unshift($dirs, getenv('HOME') . '/.cv/alias'); } return $dirs; } From 7054b463545b97efcdd615a47d5bdf8edc218fb0 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 26 Feb 2025 13:48:31 -0800 Subject: [PATCH 66/90] Tweak plugin-search and alias-search for Windows Based on similar searches elsewhere... --- lib/plugin/basic-alias.php | 3 +++ lib/src/CvPlugins.php | 3 +++ 2 files changed, 6 insertions(+) diff --git a/lib/plugin/basic-alias.php b/lib/plugin/basic-alias.php index 231002e0..2e8d25d7 100644 --- a/lib/plugin/basic-alias.php +++ b/lib/plugin/basic-alias.php @@ -86,6 +86,9 @@ public static function getFolders(): array { if (getenv('HOME')) { array_unshift($dirs, getenv('HOME') . '/.cv/alias'); } + elseif (getenv('USERPROFILE')) { + array_unshift($dirs, getenv('USERPROFILE') . '/.cv/alias'); + } return $dirs; } diff --git a/lib/src/CvPlugins.php b/lib/src/CvPlugins.php index 0dc046af..cfd2c447 100644 --- a/lib/src/CvPlugins.php +++ b/lib/src/CvPlugins.php @@ -30,6 +30,9 @@ public function init(array $pluginEnv) { if (getenv('HOME')) { array_unshift($this->paths, getenv('HOME') . '/.cv/plugin'); } + elseif (getenv('USERPROFILE')) { + array_unshift($this->paths, getenv('USERPROFILE') . '/.cv/plugin'); + } } // Always load internal plugins From 0912c2c9b4c8052664c9a53804e991e4876b28f7 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Fri, 28 Feb 2025 01:08:39 -0800 Subject: [PATCH 67/90] Simpler access to STDERR object --- lib/src/Cv.php | 12 ++++++++++++ src/Command/HttpCommand.php | 6 +++--- src/Command/StatusCommand.php | 6 ++---- src/Util/VerboseApi.php | 2 +- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/lib/src/Cv.php b/lib/src/Cv.php index 26e30db9..59489f5d 100644 --- a/lib/src/Cv.php +++ b/lib/src/Cv.php @@ -94,12 +94,24 @@ public static function input() { } /** + * Get a reference to STDOUT (with support for highlighting) for current action. + * ) * @return \CvDeps\Symfony\Component\Console\Output\OutputInterface|\Symfony\Component\Console\Output\OutputInterface */ public static function output() { return static::ioStack()->current('output'); } + /** + * Get a reference to STDERR (with support for highlighting) for current action . + * + * @return \CvDeps\Symfony\Component\Console\Output\OutputInterface|\Symfony\Component\Console\Output\OutputInterface + */ + public static function errorOutput() { + $out = static::output(); + return method_exists($out, 'getErrorOutput') ? $out->getErrorOutput() : $out; + } + /** * @return \CvDeps\Symfony\Component\Console\Style\StyleInterface|\Symfony\Component\Console\Style\StyleInterface */ diff --git a/src/Command/HttpCommand.php b/src/Command/HttpCommand.php index e7ccc5a5..cf1bff06 100644 --- a/src/Command/HttpCommand.php +++ b/src/Command/HttpCommand.php @@ -2,6 +2,7 @@ namespace Civi\Cv\Command; +use Civi\Cv\Cv; use Civi\Cv\Util\ExtensionTrait; use Civi\Cv\Util\StructuredOutputTrait; use Civi\Cv\Util\UrlCommandTrait; @@ -81,9 +82,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int */ protected function sendRequest(OutputInterface $output, string $method, string $url, array $headers = [], ?string $body = NULL): int { $method = strtoupper($method); - $errorOutput = is_callable([$output, 'getErrorOutput']) ? $output->getErrorOutput() : $output; - $verbose = function(string $text) use ($errorOutput) { - $errorOutput->writeln($text, OutputInterface::OUTPUT_RAW | OutputInterface::VERBOSITY_VERBOSE); + $verbose = function(string $text) { + Cv::errorOutput()->writeln($text, OutputInterface::OUTPUT_RAW | OutputInterface::VERBOSITY_VERBOSE); }; $verbose("> $method $url"); diff --git a/src/Command/StatusCommand.php b/src/Command/StatusCommand.php index 2b70b372..9117aca9 100644 --- a/src/Command/StatusCommand.php +++ b/src/Command/StatusCommand.php @@ -67,8 +67,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->sendTable($input, $output, $rows); if ($input->getOption('out') === 'table' && !$output->isVerbose()) { - $error = method_exists($output, 'getErrorOutput') ? $output->getErrorOutput() : $output; - $error->writeln('TIP: To see even more information, enable the verbose flag (-v).'); + Cv::errorOutput()->writeln('TIP: To see even more information, enable the verbose flag (-v).'); } return 0; @@ -188,7 +187,6 @@ private function shortCms($ufName, $ufVersion): string { * @return array */ protected function findPathsUrls(OutputInterface $output): array { - $error = method_exists($output, 'getErrorOutput') ? $output->getErrorOutput() : $output; $pathList = $urlList = []; $paths = \Civi::paths(); @@ -201,7 +199,7 @@ protected function findPathsUrls(OutputInterface $output): array { if ($output->isVerbose()) { $allVariables = property_exists($paths, 'variableFactory') ? Invasive::get([$paths, 'variableFactory']) : NULL; if (empty($allVariables)) { - $error->writeln('Failed to inspect Civi::paths()->variableFactory'); + Cv::errorOutput()->writeln('Failed to inspect Civi::paths()->variableFactory'); } else { $pathVariables = $urlVariables = array_keys($allVariables); diff --git a/src/Util/VerboseApi.php b/src/Util/VerboseApi.php index 475e774b..a0c68068 100644 --- a/src/Util/VerboseApi.php +++ b/src/Util/VerboseApi.php @@ -33,7 +33,7 @@ public static function callApi3Success($entity, $action, $params) { 'result' => $result, ); if (!empty($result['is_error'])) { - $output->getErrorOutput()->writeln("Error: API Call Failed: " + \Civi\Cv\Cv::errorOutput()->writeln("Error: API Call Failed: " . Encoder::encode($data, 'pretty')); } else { From ca806691ba0b06be17b719a17cab352f338e1ad6 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Fri, 14 Mar 2025 14:10:34 -0700 Subject: [PATCH 68/90] ConsoleQueueRunner - Fix some warnings Depending on the mix of upgrade-tasks and verbosity-flag, the console may sometimes print warnings when it tries to describe a pending queue task. (esp when the task arguments include nested arrays) --- src/Util/ConsoleQueueRunner.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Util/ConsoleQueueRunner.php b/src/Util/ConsoleQueueRunner.php index 831ead7a..8ef82f4e 100644 --- a/src/Util/ConsoleQueueRunner.php +++ b/src/Util/ConsoleQueueRunner.php @@ -107,10 +107,9 @@ public function runAll() { } protected static function formatTaskCallback(\CRM_Queue_Task $task) { - return sprintf("%s(%s)", - implode('::', (array) $task->callback), - implode(',', $task->arguments) - ); + $cb = implode('::', (array) $task->callback); + $args = json_encode($task->arguments, JSON_UNESCAPED_SLASHES); + return sprintf("%s(%s)", $cb, substr($args, 1, -1)); } } From 3dd8fcb9fb1ef96ca5c98216c3973fc7c9e9a8bc Mon Sep 17 00:00:00 2001 From: Jaap Jansma Date: Mon, 17 Mar 2025 13:04:01 +0100 Subject: [PATCH 69/90] Use XDG_STATE_HOME environment variable as a preference above HOME --- lib/src/Config.php | 3 +++ lib/src/CvPlugins.php | 5 ++++- src/Command/UpgradeDbCommand.php | 7 +++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/src/Config.php b/lib/src/Config.php index 1a09e0cc..f9a524e5 100644 --- a/lib/src/Config.php +++ b/lib/src/Config.php @@ -45,6 +45,9 @@ public static function getFileName() { if (getenv('CV_CONFIG')) { return getenv('CV_CONFIG'); } + elseif (getenv('XDG_STATE_HOME')) { + return getenv('XDG_STATE_HOME') . '/.cv.json'; + } elseif (getenv('HOME')) { return getenv('HOME') . '/.cv.json'; } diff --git a/lib/src/CvPlugins.php b/lib/src/CvPlugins.php index cfd2c447..821f9132 100644 --- a/lib/src/CvPlugins.php +++ b/lib/src/CvPlugins.php @@ -27,7 +27,10 @@ public function init(array $pluginEnv) { } else { $this->paths = ['/etc/cv/plugin', '/usr/local/share/cv/plugin', '/usr/share/cv/plugin']; - if (getenv('HOME')) { + if (getenv('XDG_STATE_HOME')) { + array_unshift($this->paths, getenv('XDG_STATE_HOME') . '/.cv/plugin'); + } + elseif (getenv('HOME')) { array_unshift($this->paths, getenv('HOME') . '/.cv/plugin'); } elseif (getenv('USERPROFILE')) { diff --git a/src/Command/UpgradeDbCommand.php b/src/Command/UpgradeDbCommand.php index 87be5804..9bdb4c7e 100644 --- a/src/Command/UpgradeDbCommand.php +++ b/src/Command/UpgradeDbCommand.php @@ -113,9 +113,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int * upgrades use the same file. */ protected function getUpgradeFile() { - $home = getenv('HOME') ? getenv('HOME') : getenv('USERPROFILE'); + $home = getenv('XDG_STATE_HOME'); if (empty($home) || !file_exists($home)) { - throw new \RuntimeException("Failed to locate HOME or USERPROFILE"); + $home = getenv('HOME') ? getenv('HOME') : getenv('USERPROFILE'); + if (empty($home) || !file_exists($home)) { + throw new \RuntimeException("Failed to locate HOME or USERPROFILE"); + } } $dir = implode(DIRECTORY_SEPARATOR, [$home, '.cv', 'upgrade']); From da05c6f75001afedcb689022779e01f481a435c8 Mon Sep 17 00:00:00 2001 From: Jaap Jansma Date: Wed, 19 Mar 2025 09:53:37 +0100 Subject: [PATCH 70/90] Update Config.php --- lib/src/Config.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/Config.php b/lib/src/Config.php index f9a524e5..6c72463a 100644 --- a/lib/src/Config.php +++ b/lib/src/Config.php @@ -45,8 +45,8 @@ public static function getFileName() { if (getenv('CV_CONFIG')) { return getenv('CV_CONFIG'); } - elseif (getenv('XDG_STATE_HOME')) { - return getenv('XDG_STATE_HOME') . '/.cv.json'; + elseif (getenv('XDG_CONFIG_HOME')) { + return getenv('XDG_CONFIG_HOME') . '/.cv.json'; } elseif (getenv('HOME')) { return getenv('HOME') . '/.cv.json'; From fc6926f6ebccefd0f292ed2c8d368093af041f40 Mon Sep 17 00:00:00 2001 From: Jaap Jansma Date: Wed, 19 Mar 2025 09:54:20 +0100 Subject: [PATCH 71/90] Update CvPlugins.php --- lib/src/CvPlugins.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/CvPlugins.php b/lib/src/CvPlugins.php index 821f9132..11c85a06 100644 --- a/lib/src/CvPlugins.php +++ b/lib/src/CvPlugins.php @@ -27,8 +27,8 @@ public function init(array $pluginEnv) { } else { $this->paths = ['/etc/cv/plugin', '/usr/local/share/cv/plugin', '/usr/share/cv/plugin']; - if (getenv('XDG_STATE_HOME')) { - array_unshift($this->paths, getenv('XDG_STATE_HOME') . '/.cv/plugin'); + if (getenv('XDG_CONFIG_HOME')) { + array_unshift($this->paths, getenv('XDG_CONFIG_HOME') . '/.cv/plugin'); } elseif (getenv('HOME')) { array_unshift($this->paths, getenv('HOME') . '/.cv/plugin'); From ac06fe8ade08cc28d3b98e392c5aef4e56695841 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Fri, 4 Apr 2025 19:37:20 -0700 Subject: [PATCH 72/90] Config::getFileName() - Continue support for pre-existing files (even if XDG var is set) --- lib/src/Config.php | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/lib/src/Config.php b/lib/src/Config.php index 6c72463a..b1561533 100644 --- a/lib/src/Config.php +++ b/lib/src/Config.php @@ -43,17 +43,33 @@ public static function update($filter) { */ public static function getFileName() { if (getenv('CV_CONFIG')) { + // The user has specifically told us where to go. return getenv('CV_CONFIG'); } - elseif (getenv('XDG_CONFIG_HOME')) { - return getenv('XDG_CONFIG_HOME') . '/.cv.json'; + + // We have to figure out where to go. There are a couple plausible locations... + $candidates = []; + if (getenv('XDG_CONFIG_HOME')) { + $candidates[] = getenv('XDG_CONFIG_HOME') . '/.cv.json'; } - elseif (getenv('HOME')) { - return getenv('HOME') . '/.cv.json'; + if (getenv('HOME')) { + $candidates[] = getenv('HOME') . '/.cv.json'; } - else { - throw new \RuntimeException("Failed to determine file path for 'cv.json'."); + + // Prefer the first extant config file... + foreach ($candidates as $candidate) { + if (file_exists($candidate)) { + return $candidate; + } } + + // Or if there is no extant file, then use the first plausible suggestion... + if (isset($candidates[0])) { + return $candidates[0]; + } + + throw new \RuntimeException("Failed to determine file path for 'cv.json'."); + } } From 1d6fae2b82e8bd02256b0621f0ec05d3b6e15e17 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Fri, 4 Apr 2025 19:43:03 -0700 Subject: [PATCH 73/90] CvPlugins - Allow mix of plugins from HOME/.cv/plugin and XDG_CONFIG_HOME/cv/plugin --- lib/src/CvPlugins.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/src/CvPlugins.php b/lib/src/CvPlugins.php index 11c85a06..9999a1ac 100644 --- a/lib/src/CvPlugins.php +++ b/lib/src/CvPlugins.php @@ -27,15 +27,15 @@ public function init(array $pluginEnv) { } else { $this->paths = ['/etc/cv/plugin', '/usr/local/share/cv/plugin', '/usr/share/cv/plugin']; - if (getenv('XDG_CONFIG_HOME')) { - array_unshift($this->paths, getenv('XDG_CONFIG_HOME') . '/.cv/plugin'); - } - elseif (getenv('HOME')) { + if (getenv('HOME')) { array_unshift($this->paths, getenv('HOME') . '/.cv/plugin'); } elseif (getenv('USERPROFILE')) { array_unshift($this->paths, getenv('USERPROFILE') . '/.cv/plugin'); } + if (getenv('XDG_CONFIG_HOME')) { + array_unshift($this->paths, getenv('XDG_CONFIG_HOME') . '/cv/plugin'); + } } // Always load internal plugins From fea0e0a95fcdb1afdebbcbeb7a1ffb128131721f Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Fri, 4 Apr 2025 19:43:16 -0700 Subject: [PATCH 74/90] basic-alias plugin - Also search XDG_CONFIG_HOME/cv/alias --- lib/plugin/basic-alias.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/plugin/basic-alias.php b/lib/plugin/basic-alias.php index 2e8d25d7..2621de04 100644 --- a/lib/plugin/basic-alias.php +++ b/lib/plugin/basic-alias.php @@ -89,6 +89,9 @@ public static function getFolders(): array { elseif (getenv('USERPROFILE')) { array_unshift($dirs, getenv('USERPROFILE') . '/.cv/alias'); } + if (getenv('XDG_CONFIG_HOME')) { + array_unshift($dirs, getenv('XDG_CONFIG_HOME') . '/cv/alias'); + } return $dirs; } From 7b36335228127efcbdd4a09b1b0389edfecbc82a Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 7 Apr 2025 17:14:31 -0700 Subject: [PATCH 75/90] cv status - Add OS flags for plesk, cpanel, webmin --- src/Command/StatusCommand.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Command/StatusCommand.php b/src/Command/StatusCommand.php index 9117aca9..f0f4268a 100644 --- a/src/Command/StatusCommand.php +++ b/src/Command/StatusCommand.php @@ -110,6 +110,15 @@ private function longOs(): string { if (file_exists('/nix')) { $parens['nix'] = 1; } + if (file_exists('/opt/plesk')) { + $parens['plesk'] = 1; + } + if (file_exists('/var/cpanel') || file_exists('/usr/local/cpanel')) { + $parens['cpanel'] = 1; + } + if (file_exists('/etc/webmin')) { + $parens['webmin'] = 1; + } return sprintf('%s (%s)', $main, implode(', ', array_keys($parens))); } From c880e545a0de9fe340e79039db5e679160587401 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 7 Apr 2025 17:14:53 -0700 Subject: [PATCH 76/90] cv status - Add PHP flag for plesk binaries --- src/Command/StatusCommand.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Command/StatusCommand.php b/src/Command/StatusCommand.php index f0f4268a..c8c0465f 100644 --- a/src/Command/StatusCommand.php +++ b/src/Command/StatusCommand.php @@ -149,6 +149,10 @@ private function longPhp(): string { $parens['usr-bin'] = 1; unset($parens['other']); } + if (preg_match(';^/opt/plesk/;', $binary)) { + $parens['plesk'] = 1; + unset($parens['other']); + } } return sprintf('%s (%s)', PHP_VERSION, implode(', ', array_keys($parens))); From 6175b654eae07d405bc940268735ffcc4d7fa6da Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 7 Apr 2025 17:19:57 -0700 Subject: [PATCH 77/90] cv status - Improve reporting about MariaDB in summary --- src/Command/StatusCommand.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Command/StatusCommand.php b/src/Command/StatusCommand.php index c8c0465f..f94339f2 100644 --- a/src/Command/StatusCommand.php +++ b/src/Command/StatusCommand.php @@ -163,11 +163,15 @@ private function shortPhp($version): string { } private function shortDbms($version): string { - if (str_contains($version, 'Maria')) { - // FIXME: ex: 10.5 ==> r105 + if (preg_match('/([0-9]+)\.([0-9]+).*.*MariaDB/', $version, $matches)) { + // Ex: 10.6.2-MariaDB => r106 + return 'r' . $matches[1] . $matches[2]; + } + elseif (str_contains($version, 'Maria')) { return 'r???'; } else { + // Ex: 8.0.5 => m80 return 'm' . preg_replace('/([0-9]+)\.([0-9]+).*$/', '$1$2', $version); } } From 33ba96bc4dcbca68b93d2eebb7bea082a025e353 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 7 Aug 2023 16:33:53 -0700 Subject: [PATCH 78/90] Define `Top` helper to load classes regardless of namespace prefixing Currently, `cv` has a list of php-scoper rules to exclude certain classes from prefixing. The same list has to be adapted to use in `civix` - so, presumbly, they'll have to be sync'd over time. The main reason for this is to allow the PHAR to call-out to the UF during bootstrap -- and each UF has a different list of rules. In theory, we could drop these rules -- and replace any lines like `drupal_bootstrap()` with `Top::call('drupal_bootstrap')`. Then there would be no need to keep the rules in sync. Additionally, it would allow `cv.phar` to take advantage of libraries (like `symfony/event`) that might be dual-purpose. (Ex: cv.phar and UF both have `EventDispatcher`s; and you want to add listeners to both of them.) --- lib/src/Top.php | 93 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 lib/src/Top.php diff --git a/lib/src/Top.php b/lib/src/Top.php new file mode 100644 index 00000000..cc83be14 --- /dev/null +++ b/lib/src/Top.php @@ -0,0 +1,93 @@ + Date: Tue, 8 Apr 2025 22:23:18 -0700 Subject: [PATCH 79/90] (REF) Process - Define lazyEscape() --- src/Util/Process.php | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/Util/Process.php b/src/Util/Process.php index b778438b..48d841fa 100644 --- a/src/Util/Process.php +++ b/src/Util/Process.php @@ -17,7 +17,7 @@ public static function sprintf($expr) { $newArgs = array(); $newArgs[] = array_shift($args); foreach ($args as $arg) { - $newArgs[] = preg_match(';^[a-zA-Z0-9\.\/]+$;', $arg) ? $arg : escapeshellarg($arg); + $newArgs[] = static::lazyEscape($arg); } return call_user_func_array('sprintf', $newArgs); } @@ -125,4 +125,22 @@ public static function dump(\Symfony\Component\Process\Process $process) { )); } + /** + * Escape a value for use as a shell argument. + * + * This is basically the same as `escapeshellarg()`, but quotation marks can be skipped for + * some simple strings. + * + * @param string $value + * @return string + */ + public static function lazyEscape(string $value): string { + if (preg_match('/^[a-zA-Z0-9_\.\-\/=]*$/', $value)) { + return $value; + } + else { + return escapeshellarg($value); + } + } + } From 87748d3e55eea4c3f025ba39981f3f5732dbf2ac Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 8 Apr 2025 22:40:00 -0700 Subject: [PATCH 80/90] Cv::passthru() - Construct smarter command * Pass-through the path of the current PHP and cv binaries * Pass-through arguments like --level and --user --- bin/cv | 1 + src/Util/Cv.php | 61 +++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/bin/cv b/bin/cv index 09b77236..0ed40ad0 100755 --- a/bin/cv +++ b/bin/cv @@ -1,5 +1,6 @@ #!/usr/bin/env php isDebug()) { + $output->writeln("Run subcommand ($fullCmd)"); + $output->writeln(''); + } $process = proc_open( - $cmd, + $fullCmd, array( // 0 => array('pipe', 'r'), 0 => STDIN, @@ -84,4 +91,54 @@ public static function passthru($cmd) { return proc_close($process); } + public static function getCvCommand(): string { + $executableFinder = new PhpExecutableFinder(); + $php = $executableFinder->find(FALSE); + if (!$php) { + throw new \RuntimeException("Unable to find the PHP executable"); + } + $parts = $executableFinder->findArguments(); + if (preg_match(';^phar://(.*)/bin/cv$;', CV_BIN, $matches)) { + $parts[] = $matches[1]; + } + else { + $parts[] = CV_BIN; + } + $parts = array_merge($parts, static::getPassthruOptions()); + return $php . ' ' . implode(' ', array_map([Process::class, 'lazyEscape'], $parts)); + } + + protected static function getPassthruOptions(): array { + $input = \Civi\Cv\Cv::input(); + $output = \Civi\Cv\Cv::output(); + $parts = []; + foreach (['level', 'url', 'user'] as $option) { + if ($input->getOption($option)) { + $parts[] = '--' . $option . '=' . $input->getOption($option); + } + } + foreach (['test'] as $option) { + if ($input->getOption($option)) { + $parts[] = '--' . $option; + } + } + if ($output->isDebug()) { + $parts[] = '-vvv'; + } + elseif ($output->isVeryVerbose()) { + $parts[] = '-vv'; + } + elseif ($output->isVerbose()) { + $parts[] = '-v'; + } + if ($output->isQuiet()) { + $parts[] = '--quiet'; + } + $parts[] = $output->isDecorated() ? '--ansi' : '--no-ansi'; + if (!$input->isInteractive()) { + $parts[] = '--no-interaction'; + } + return $parts; + } + } From 3e8cbde36a1e2b9988d6128ce3b5f9a91a05cd31 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 9 Apr 2025 10:20:49 -0700 Subject: [PATCH 81/90] ConsoleQueueRunner - Tweak output --- src/Util/ConsoleQueueRunner.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Util/ConsoleQueueRunner.php b/src/Util/ConsoleQueueRunner.php index 8ef82f4e..9e7630ab 100644 --- a/src/Util/ConsoleQueueRunner.php +++ b/src/Util/ConsoleQueueRunner.php @@ -93,7 +93,7 @@ public function runAll() { } catch (\Exception $e) { // WISHLIST: For interactive mode, perhaps allow retry/skip? - $io->writeln(sprintf("Error executing task \"%s\"", $task->title)); + $io->writeln(sprintf("Error executing task: %s", $task->title)); throw $e; } } From 1f666c01c01897408f87abb5debac1739ba4f622 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 9 Apr 2025 10:44:38 -0700 Subject: [PATCH 82/90] ConsoleQueueRunner - Use named exception --- src/Exception/QueueTaskException.php | 5 +++++ src/Util/ConsoleQueueRunner.php | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 src/Exception/QueueTaskException.php diff --git a/src/Exception/QueueTaskException.php b/src/Exception/QueueTaskException.php new file mode 100644 index 00000000..3e0e5e9b --- /dev/null +++ b/src/Exception/QueueTaskException.php @@ -0,0 +1,5 @@ +run($taskCtx); if (!$isOK) { - throw new \Exception('Task returned false'); + throw new QueueTaskException('Task returned false'); } } - catch (\Exception $e) { + catch (\Throwable $e) { // WISHLIST: For interactive mode, perhaps allow retry/skip? $io->writeln(sprintf("Error executing task: %s", $task->title)); throw $e; From caf59aca023d8389165ee37935f10206beab887d Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 8 Apr 2025 22:42:46 -0700 Subject: [PATCH 83/90] Implement ConsoleSubprocessQueueRunner (with internal subcmd, console-queue:run-next) --- src/Application.php | 1 + src/Command/QueueNextCommand.php | 90 +++++++++++++ src/Util/ConsoleSubprocessQueueRunner.php | 153 ++++++++++++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 src/Command/QueueNextCommand.php create mode 100644 src/Util/ConsoleSubprocessQueueRunner.php diff --git a/src/Application.php b/src/Application.php index 6a6f6ca3..277eeec3 100644 --- a/src/Application.php +++ b/src/Application.php @@ -87,6 +87,7 @@ public function createCommands($context = 'default') { $commands[] = new \Civi\Cv\Command\CoreCheckReqCommand(); $commands[] = new \Civi\Cv\Command\CoreInstallCommand(); $commands[] = new \Civi\Cv\Command\CoreUninstallCommand(); + $commands[] = new \Civi\Cv\Command\QueueNextCommand(); $commands[] = new \Stecman\Component\Symfony\Console\BashCompletion\CompletionCommand(); } return $commands; diff --git a/src/Command/QueueNextCommand.php b/src/Command/QueueNextCommand.php new file mode 100644 index 00000000..e4fce800 --- /dev/null +++ b/src/Command/QueueNextCommand.php @@ -0,0 +1,90 @@ +setName('console-queue:run-next') + ->setDescription('(INTERNAL) Run the next task in queue') + ->setHidden(TRUE) + ->addOption('steal', NULL, InputOption::VALUE_NONE) + ->addOption('skip', NULL, InputOption::VALUE_NONE) + ->addOption('out', NULL, InputOption::VALUE_REQUIRED, 'Store outcome in a JSON file') + ->addOption('queue', NULL, InputOption::VALUE_REQUIRED, 'Queue name') + ->addOption('queue-spec', NULL, InputOption::VALUE_REQUIRED, 'Queue specification (Base64-JSON)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $outFile = $input->getOption('out'); + if ($outFile && file_exists($outFile)) { + $this->writeFile($outFile, ''); + } + + $queue = $this->getQueue($input); + + $runner = new CRM_Queue_Runner([ + 'queue' => $queue, + ]); + if ($input->getOption('skip')) { + $result = $runner->skipNext($input->getOption('steal')); + } + else { + $result = $runner->runNext($input->getOption('steal')); + } + + if ($outFile) { + $this->writeFile($outFile, json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + } + + if (!empty($result['exception'])) { + throw $result['exception']; + } + return empty($result['is_error']) ? 0 : 1; + } + + private function writeFile(string $outFile, string $data): void { + $parent = dirname($outFile); + if (!is_dir($parent)) { + if (!mkdir($parent, 0777, TRUE)) { + throw new \RuntimeException("Failed to create directory $parent"); + } + } + $result = file_put_contents($outFile, $data); + if ($result === FALSE) { + throw new \RuntimeException("Failed to write to $outFile"); + } + } + + /** + * @param \Symfony\Component\Console\Input\InputInterface $input + * @return \CRM_Queue_Queue + */ + private function getQueue(InputInterface $input): \CRM_Queue_Queue { + // cv is evergreen and may be used on old versions, so we accept either --queue-spec (old style, non-persistent queues) + // or --queue (new style, persistent queues). + if ($input->getOption('queue-spec')) { + // For old-fashioned systems which lack support for persistent queue-definitions. + $spec = json_decode(base64_decode($input->getOption('queue-spec')), TRUE); + if (empty($spec)) { + throw new \InvalidArgumentException('Queue spec is empty or malformed'); + } + $queue = \CRM_Queue_Service::singleton()->create($spec); + } + elseif ($input->getOption('queue')) { + $queue = Civi::queue($input->getOption('queue')); + } + else { + throw new \LogicException("Must specify either --queue or --queue-spec"); + } + return $queue; + } + +} diff --git a/src/Util/ConsoleSubprocessQueueRunner.php b/src/Util/ConsoleSubprocessQueueRunner.php new file mode 100644 index 00000000..08d147dd --- /dev/null +++ b/src/Util/ConsoleSubprocessQueueRunner.php @@ -0,0 +1,153 @@ +io = $io; + $this->queueSpec = $queueSpec; + $this->dryRun = $dryRun; + $this->step = (bool) $step; + $this->stateFile = $this->pickStateFile(); + } + + public function __destruct() { + if ($this->stateFile && file_exists($this->stateFile)) { + @unlink($this->stateFile); + } + } + + private function pickStateFile(): string { + $id = \uniqid(); + if (getenv('XDG_STATE_HOME')) { + return implode(DIRECTORY_SEPARATOR, [getenv('XDG_STATE_HOME'), 'cv', "dl-{$id}.json"]); + } + $home = getenv('HOME') ? getenv('HOME') : getenv('USERPROFILE'); + if (!empty($home) && file_exists($home)) { + return implode(DIRECTORY_SEPARATOR, [getenv('HOME'), '.cv', 'state', "dl-{$id}.json"]); + } + throw new \RuntimeException("Failed to pick state-file. Please set one of: HOME, USERPROFILE, XDG_STATE_HOME"); + } + + private function getState(): ?array { + if (!file_exists($this->stateFile)) { + return NULL; + } + return json_decode(file_get_contents($this->stateFile), TRUE); + } + + /** + * @throws \Exception + */ + public function runAll() { + /** @var \Symfony\Component\Console\Style\SymfonyStyle $io */ + $io = $this->io; + $queue = \CRM_Queue_Service::singleton()->create($this->queueSpec); + + while ($queue->numberOfItems()) { + // In case we're retrying a failed job. + $item = $queue->stealItem(); + $task = $item->data; + + if ($io->getVerbosity() === OutputInterface::VERBOSITY_NORMAL) { + // Symfony progress bar would be prettier, but (when last checked) they didn't allow + // resetting when the queue-length expands dynamically. + $io->write("."); + } + elseif ($io->getVerbosity() === OutputInterface::VERBOSITY_VERBOSE) { + $io->writeln(sprintf("%s", $task->title)); + } + elseif ($io->getVerbosity() > OutputInterface::VERBOSITY_VERBOSE) { + $io->writeln(sprintf("%s (%s)", $task->title, self::formatTaskCallback($task))); + } + + $action = 'y'; + if ($this->step) { + $action = $io->choice('Execute this step?', ['y' => 'yes', 's' => 'skip', 'a' => 'abort'], 'y'); + } + if ($action === 'a') { + throw new \Exception('Aborted'); + } + + $specArg = escapeshellarg(base64_encode(json_encode($this->queueSpec, JSON_UNESCAPED_SLASHES))); + $fileArg = escapeshellarg($this->stateFile); + $runCmd = "console-queue:run-next --queue-spec=$specArg --steal --out=$fileArg " . ($action === 's' ? '--skip' : ''); + if ($this->dryRun) { + $io->writeln(sprintf("DRY-RUN cv %s", $runCmd)); + $queue->deleteItem($item); + } + else { + $exitCode = Cv::passthru($runCmd); + $state = $this->getState(); + if ($exitCode || !empty($state['is_error'])) { + // WISHLIST: For interactive mode, perhaps allow retry/skip? + $io->writeln(''); + $io->writeln(sprintf("Error executing task: %s", $task->title)); + if ($io->isDebug()) { + $io->writeln('Subprocess results: ' . json_encode($state, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), OutputInterface::OUTPUT_PLAIN); + } + throw new QueueTaskException('Task returned error'); + } + } + } + + if ($io->getVerbosity() === OutputInterface::VERBOSITY_NORMAL) { + $io->newLine(); + } + } + + protected static function formatTaskCallback(\CRM_Queue_Task $task) { + $cb = implode('::', (array) $task->callback); + $args = json_encode($task->arguments, JSON_UNESCAPED_SLASHES); + return sprintf("%s(%s)", $cb, substr($args, 1, -1)); + } + +} From 35504ed1e1d08596aba54c5d16c0373c78df1187 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 8 Apr 2025 12:33:10 -0700 Subject: [PATCH 84/90] Process extension-download steps in separate subprocesses --- src/Command/ExtensionDownloadCommand.php | 174 +++++++++++++++++++---- 1 file changed, 147 insertions(+), 27 deletions(-) diff --git a/src/Command/ExtensionDownloadCommand.php b/src/Command/ExtensionDownloadCommand.php index 105f0591..0ac90393 100644 --- a/src/Command/ExtensionDownloadCommand.php +++ b/src/Command/ExtensionDownloadCommand.php @@ -1,6 +1,9 @@ addOption('force', 'f', InputOption::VALUE_NONE, 'If an extension already exists, download it anyway.') ->addOption('to', NULL, InputOption::VALUE_OPTIONAL, 'Download to a specific directory (absolute path).') ->addOption('keep', 'k', InputOption::VALUE_NONE, 'If an extension already exists, keep it.') + ->addOption('dry-run', NULL, InputOption::VALUE_NONE, 'Preview the list of tasks') + ->addOption('step', NULL, InputOption::VALUE_NONE, 'Run the tasks in steps, pausing before each step') ->addArgument('key-or-name', InputArgument::IS_ARRAY, 'One or more extensions to enable. Identify the extension by full key ("org.example.foobar") or short name ("foobar"). Optionally append a URL.') ->setHelp('Download and enable an extension @@ -67,6 +72,9 @@ protected function initialize(InputInterface $input, OutputInterface $output) { if ($input->hasOption('bare') && $input->getOption('bare')) { $input->setOption('level', 'none'); $input->setOption('no-install', TRUE); + if (empty($input->getOption('to'))) { + throw new \LogicException("If --bare is specified, then --to must also be specified."); + } } if ($extRepoUrl = $this->parseRepoUrl($input)) { global $civicrm_setting; @@ -76,6 +84,12 @@ protected function initialize(InputInterface $input, OutputInterface $output) { } protected function execute(InputInterface $input, OutputInterface $output): int { + if ($input->getOption('step')) { + if ($output->getVerbosity() < OutputInterface::VERBOSITY_VERY_VERBOSE) { + $output->setVerbosity(OutputInterface::VERBOSITY_VERY_VERBOSE); + } + } + $fs = new Filesystem(); if ($input->getOption('to') && !$fs->isAbsolutePath($input->getOption('to'))) { @@ -100,7 +114,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - [$downloads, $errors] = $this->parseDownloads($input); + [$requestedDownloads, $errors] = $this->parseDownloads($input); if ($refresh == 'auto' && !empty($errors)) { $output->writeln("Extension cache does not contain requested item(s)"); $refresh = 'yes'; @@ -115,59 +129,165 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->getErrorOutput()->writeln("$error"); } $output->getErrorOutput()->writeln("Tip: To customize the feed, review options in \"cv {$input->getFirstArgument()} --help\""); - $output->getErrorOutput()->writeln("Tip: To browse available downloads, run \"cv ext:list -R\""); + $output->getErrorOutput()->writeln("Tip: To browse available requestedDownloads, run \"cv ext:list -R\""); return 1; } - if ($input->getOption('to') && count($downloads) > 1) { + if ($input->getOption('to') && count($requestedDownloads) > 1) { throw new \RuntimeException("When specifying --to, you can only download one extension at a time."); } + elseif (empty($requestedDownloads)) { + Cv::output()->writeln('Nothing to do.'); + return 0; + } + elseif ($input->getOption('bare')) { + return $this->executeWithBare($requestedDownloads); + } + else { + return $this->executeWithQueue($requestedDownloads); + } + } - foreach ($downloads as $key => $url) { - $action = $this->pickAction($input, $output, $key); + /** + * In a bare download, we don't have access to a copy of CiviCRM. This is useful + * if you want to grab a specific extension from a feed (before Civi is installed). + * + * Ex: cv dl -b "@https://civicrm.org/extdir/ver=$DM_VERSION/$SOME_EXT.xml" --to="$SOME_FOLDER" + * + * @param array $requestedDownloads + * @return int + */ + protected function executeWithBare(array $requestedDownloads): int { + foreach ($requestedDownloads as $key => $url) { + $action = $this->pickAction(Cv::input(), Cv::output(), $key); switch ($action) { case 'download': - if ($to = $input->getOption('to')) { - $output->writeln("Downloading extension \"$key\" ($url) to \"$to\""); + if ($to = Cv::input()->getOption('to')) { + Cv::output()->writeln("Downloading extension \"$key\" ($url) to \"$to\""); $dl = new HeadlessDownloader(); - $dl->run($url, $key, $input->getOption('to'), TRUE); + $dl->run($url, $key, Cv::input()->getOption('to'), TRUE); } else { - $output->writeln("Downloading extension \"$key\" ($url)"); - $this->assertBooted(); - $result = VerboseApi::callApi3Success('Extension', 'download', array( - 'key' => $key, - 'url' => $url, - 'install' => !$input->getOption('no-install'), - )); + throw new \LogicException("Missing option --to"); } break; + case 'abort': + Cv::output()->writeln("Aborted"); + return 1; + + case 'install': + case 'skip': + Cv::output()->writeln("Skipped extension \"$key\"."); + break; + + default: + throw new \RuntimeException("Unrecognized action: $action"); + } + } + + return 0; + } + + /** + * Perform a normal download. This may involve several steps, such as fetching a ZIP, + * extracting it, putting it inplace (replacing an old ext), flushing caches, + * running upgrades, etc. + * + * This runs in a queue, and each queue item is launched as a separate subprocess. + * + * @param array $requestedDownloads + * @return int + */ + protected function executeWithQueue(array $requestedDownloads): int { + $this->assertBooted(); + + if (Cv::input()->getOption('to')) { + // FIXME + throw new \RuntimeException('Queued downloader does not currently support --to'); + } + + $queueSpec = $this->createQueueSpec(); + $newQueueSpec = array_merge($queueSpec, ['reset' => TRUE]); + $reloadQueueSpec = array_merge($queueSpec, ['reset' => FALSE]); + $queue = \CRM_Queue_Service::singleton()->create($newQueueSpec); + + if (class_exists('CRM_Extension_QueueTasks')) { + $downloader = new \CRM_Extension_QueueDownloader(TRUE, $queue); + $runner = new ConsoleSubprocessQueueRunner(Cv::io(), $reloadQueueSpec, Cv::input()->getOption('dry-run'), Cv::input()->getOption('step')); + } + else { + throw new \Exception("TODO: Implement polyfill for CRM_Extension_QueueDownloader"); + } + + $batch = NULL; + $updateBatch = function(?string $method, array $data) use (&$batch, $downloader) { + if ($batch === NULL) { + $batch = ['method' => $method, 'data' => $data]; + } + elseif ($batch['method'] === $method) { + $batch['data'] = array_merge($batch['data'], $data); + } + else { + if ($batch['method'] === 'addDownloads') { + $downloader->addDownloads($batch['data'], !Cv::input()->getOption('no-install')); + } + elseif ($batch['method'] === 'addEnable') { + $downloader->addEnable($batch['data']); + } + else { + throw new \LogicException("Unrecognized method: $method"); + } + $batch = ['method' => $method, 'data' => $data]; + } + }; + + foreach ($requestedDownloads as $key => $url) { + $action = $this->pickAction(Cv::input(), Cv::output(), $key); + switch ($action) { + case 'download': + $updateBatch('addDownloads', [$key => $url]); + break; + case 'install': - $output->writeln("Found extension \"$key\". Enabling."); - $result = VerboseApi::callApi3Success('Extension', 'enable', array( - 'key' => $key, - )); + $updateBatch('addEnable', [$key]); break; case 'abort': - $output->writeln("Aborted"); + Cv::output()->writeln("Aborted"); return 1; case 'skip': - $output->writeln("Skipped extension \"$key\"."); + Cv::output()->writeln("Skipped extension \"$key\"."); break; default: throw new \RuntimeException("Unrecognized action: $action"); } + } - if (!empty($result['is_error'])) { - return 1; - } + $updateBatch(NULL, []); + $downloader->fillQueue(); + try { + $runner->runAll(); + return 0; } + catch (QueueTaskException $e) { + // The earlier handlers will output details. + return 1; + } + } - return 0; + protected function createQueueSpec(): array { + return [ + 'name' => 'cli-ext-dl', + 'type' => 'Sql', + 'runner' => 'task', + 'is_autorun' => FALSE, + 'retry_limit' => 0, + 'error' => 'abort', + 'is_persistent' => FALSE, + ]; } /** @@ -318,7 +438,7 @@ protected function pickAction( return 'download'; } elseif ($input->getOption('keep')) { - return $input->getOptions('no-install') ? 'skip' : 'install'; + return $input->getOption('no-install') ? 'skip' : 'install'; } elseif ($input->getOption('force')) { return 'download'; @@ -339,7 +459,7 @@ protected function pickAction( return 'download'; case 'k': - return $input->getOptions('no-install') ? 'skip' : 'install'; + return $input->getOption('no-install') ? 'skip' : 'install'; case 'a': default: From 6a520f3d1fcb189184e2678403e8b8c886c2eda6 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 9 Apr 2025 17:42:33 -0700 Subject: [PATCH 85/90] Add polyfill for CRM_Extension_QueueDownloader (for CiviCRM <6.1) --- src/Command/ExtensionDownloadCommand.php | 7 +- src/ExtensionPolyfill/PfHelper.php | 231 +++++++++++++++++ src/ExtensionPolyfill/PfQueueDownloader.php | 267 ++++++++++++++++++++ src/ExtensionPolyfill/PfQueueTasks.php | 155 ++++++++++++ 4 files changed, 658 insertions(+), 2 deletions(-) create mode 100644 src/ExtensionPolyfill/PfHelper.php create mode 100644 src/ExtensionPolyfill/PfQueueDownloader.php create mode 100644 src/ExtensionPolyfill/PfQueueTasks.php diff --git a/src/Command/ExtensionDownloadCommand.php b/src/Command/ExtensionDownloadCommand.php index 0ac90393..c027f5a4 100644 --- a/src/Command/ExtensionDownloadCommand.php +++ b/src/Command/ExtensionDownloadCommand.php @@ -3,6 +3,7 @@ use Civi\Cv\Cv; use Civi\Cv\Exception\QueueTaskException; +use Civi\Cv\ExtensionPolyfill\PfQueueDownloader; use Civi\Cv\Util\ConsoleSubprocessQueueRunner; use Civi\Cv\Util\ExtensionTrait; use Civi\Cv\Util\Filesystem; @@ -212,12 +213,14 @@ protected function executeWithQueue(array $requestedDownloads): int { $reloadQueueSpec = array_merge($queueSpec, ['reset' => FALSE]); $queue = \CRM_Queue_Service::singleton()->create($newQueueSpec); - if (class_exists('CRM_Extension_QueueTasks')) { + if (class_exists('CRM_Extension_QueueDownloader') && class_exists('CRM_Extension_QueueTasks')) { + // Note: 6.1.0 has QueueDownloader but an insufficient signature. Presence of QueueTasks correlates with revised signature (6.1.1-ish). $downloader = new \CRM_Extension_QueueDownloader(TRUE, $queue); $runner = new ConsoleSubprocessQueueRunner(Cv::io(), $reloadQueueSpec, Cv::input()->getOption('dry-run'), Cv::input()->getOption('step')); } else { - throw new \Exception("TODO: Implement polyfill for CRM_Extension_QueueDownloader"); + $downloader = new PfQueueDownloader(TRUE, $queue); + $runner = new ConsoleSubprocessQueueRunner(Cv::io(), $reloadQueueSpec, Cv::input()->getOption('dry-run'), Cv::input()->getOption('step')); } $batch = NULL; diff --git a/src/ExtensionPolyfill/PfHelper.php b/src/ExtensionPolyfill/PfHelper.php new file mode 100644 index 00000000..576cb4a7 --- /dev/null +++ b/src/ExtensionPolyfill/PfHelper.php @@ -0,0 +1,231 @@ +getDownloader()->tmpDir; + + $zip = new ZipArchive(); + $res = $zip->open($zipFile); + if ($res === TRUE) { + $zipSubDir = CRM_Utils_Zip::guessBasedir($zip, $key); + if ($zipSubDir === FALSE) { + Civi::log()->error('Unable to extract the extension: bad directory structure'); + CRM_Core_Session::setStatus(ts('Unable to extract the extension: bad directory structure'), '', 'error'); + return FALSE; + } + $extractedZipPath = $extractTo . DIRECTORY_SEPARATOR . $zipSubDir; + if (is_dir($extractedZipPath)) { + if (!CRM_Utils_File::cleanDir($extractedZipPath, TRUE, FALSE)) { + Civi::log()->error('Unable to extract the extension {extension}: {path} cannot be cleared', [ + 'extension' => $key, + 'path' => $extractedZipPath, + ]); + CRM_Core_Session::setStatus(ts('Unable to extract the extension: %1 cannot be cleared', [1 => $extractedZipPath]), ts('Installation Error'), 'error'); + return FALSE; + } + } + if (!$zip->extractTo($extractTo)) { + Civi::log()->error('Unable to extract the extension to {path}.', ['path' => $extractTo]); + CRM_Core_Session::setStatus(ts('Unable to extract the extension to %1.', [1 => $extractTo]), ts('Installation Error'), 'error'); + return FALSE; + } + $zip->close(); + } + else { + Civi::log()->error('Unable to extract the extension'); + CRM_Core_Session::setStatus(ts('Unable to extract the extension'), '', 'error'); + return FALSE; + } + + return $extractedZipPath; + } + + /** + * NOTE: Older versions of CRM_Extension_Manager did not have this method. + * + * @see \CRM_Extension_Manager::checkInstallRequirements() + */ + public static function checkInstallRequirements(array $installKeys, $newInfos = NULL): array { + $manager = CRM_Extension_System::singleton()->getManager(); + $errors = []; + $requiredExtensions = static::findInstallRequirements($installKeys, $newInfos); + $installKeysSummary = implode(',', $requiredExtensions); + foreach ($requiredExtensions as $extension) { + if ($manager->getStatus($extension) !== CRM_Extension_Manager::STATUS_INSTALLED && !in_array($extension, $installKeys)) { + $requiredExtensionInfo = CRM_Extension_System::singleton()->getBrowser()->getExtension($extension); + $requiredExtensionInfoName = empty($requiredExtensionInfo->name) ? $extension : $requiredExtensionInfo->name; + $errors[] = [ + 'title' => ts('Missing Requirement: %1', [1 => $extension]), + 'message' => ts('You will not be able to install/upgrade %1 until you have installed the %2 extension.', [1 => $installKeysSummary, 2 => $requiredExtensionInfoName]), + ]; + } + } + return $errors; + } + + /** + * NOTE: Older versions of Manager::findInstallRequirements() were less accepting of $newInfos. + * @see \CRM_Extension_Manager::findInstallRequirements() + */ + public static function findInstallRequirements($keys, $newInfos = NULL) { + $mapper = CRM_Extension_System::singleton()->getMapper(); + if (is_object($newInfos)) { + $infos[$newInfos->key] = $newInfos; + } + elseif (is_array($newInfos)) { + $infos = $newInfos; + } + else { + $infos = $mapper->getAllInfos(); + } + // array(string $key). + $todoKeys = array_unique($keys); + // array(string $key => 1); + $doneKeys = []; + $sorter = Civi\Cv\Top::create('MJS\TopSort\Implementations\FixedArraySort'); + + while (!empty($todoKeys)) { + $key = array_shift($todoKeys); + if (isset($doneKeys[$key])) { + continue; + } + $doneKeys[$key] = 1; + + /** @var \CRM_Extension_Info $info */ + $info = @$infos[$key]; + + if ($info && $info->requires) { + $sorter->add($key, $info->requires); + $todoKeys = array_merge($todoKeys, $info->requires); + } + else { + $sorter->add($key, []); + } + } + return $sorter->sort(); + } + + /** + * NOTE: Older versions of Manager::replace() did not support $backupCodeDir or $refresh. Hence this port. + * + * Install or upgrade the code for an extension -- and perform any + * necessary database changes (eg replacing extension metadata). + * + * This only works if the extension is stored in the default container. + * + * @param string $tmpCodeDir + * Path to a local directory containing a copy of the new (inert) code. + * @param string|null $backupCodeDir + * Optionally move the old code to $backupCodeDir + * @return string + * The final path where the extension has been loaded. + * @throws CRM_Extension_Exception + * @see CRM_Extension_Manager::replace() + */ + public static function basicReplace(string $tmpCodeDir, ?string $backupCodeDir = NULL): string { + $defaultContainer = CRM_Extension_System::singleton()->getDefaultContainer(); + $fullContainer = CRM_Extension_System::singleton()->getFullContainer(); + $manager = CRM_Extension_System::singleton()->getManager(); + + if (!$defaultContainer) { + throw new CRM_Extension_Exception("Default extension container is not configured"); + } + + $newInfo = CRM_Extension_Info::loadFromFile($tmpCodeDir . DIRECTORY_SEPARATOR . CRM_Extension_Info::FILENAME); + if ($newInfo->type !== 'module') { + throw new \CRM_Extension_Exception("PfHelper::replace() only supports module extensions"); + } + + $oldStatus = $manager->getStatus($newInfo->key); + + // find $tgtPath + try { + // We prefer to put the extension in the same place (where it already exists). + $tgtPath = $fullContainer->getPath($newInfo->key); + } + catch (CRM_Extension_Exception_MissingException $e) { + // the extension does not exist in any container; we're free to put it anywhere + $tgtPath = $defaultContainer->getBaseDir() . DIRECTORY_SEPARATOR . $newInfo->key; + } + if (!CRM_Utils_File::isChildPath($defaultContainer->getBaseDir(), $tgtPath, FALSE)) { + // But if we don't control the folder, then force installation in the default-container + $oldPath = $tgtPath; + $tgtPath = $defaultContainer->getBaseDir() . DIRECTORY_SEPARATOR . $newInfo->key; + CRM_Core_Session::setStatus(ts('A copy of the extension (%1) is in a system folder (%2). The system copy will be preserved, but the new copy will be used.', [ + 1 => $newInfo->key, + 2 => $oldPath, + ]), '', 'alert', ['expires' => 0]); + } + + if ($backupCodeDir && is_dir($tgtPath)) { + if (!rename($tgtPath, $backupCodeDir)) { + throw new CRM_Extension_Exception("Failed to move $tgtPath to backup $backupCodeDir"); + } + } + + // move the code! + if (!CRM_Utils_File::replaceDir($tmpCodeDir, $tgtPath)) { + throw new CRM_Extension_Exception("Failed to move $tmpCodeDir to $tgtPath"); + } + switch ($oldStatus) { + case CRM_Extension_Manager::STATUS_INSTALLED: + case CRM_Extension_Manager::STATUS_INSTALLED_MISSING: + case CRM_Extension_Manager::STATUS_DISABLED: + case CRM_Extension_Manager::STATUS_DISABLED_MISSING: + $reflection = new \ReflectionMethod($manager, '_updateExtensionEntry'); + $reflection->setAccessible(TRUE); + $reflection->invokeArgs($manager, [$newInfo]); + + if (class_exists('Civi\Core\ClassScanner')) { + \Civi\Core\ClassScanner::cache('structure')->flush(); + \Civi\Core\ClassScanner::cache('index')->flush(); + } + break; + } + + return $tgtPath; + } + +} diff --git a/src/ExtensionPolyfill/PfQueueDownloader.php b/src/ExtensionPolyfill/PfQueueDownloader.php new file mode 100644 index 00000000..7076c636 --- /dev/null +++ b/src/ExtensionPolyfill/PfQueueDownloader.php @@ -0,0 +1,267 @@ +addDownloads( + * ['ext-1' => 'https://example.com/ext-1/releases/1.2.3./zip'] + * ); + * $runner = new CRM_Queue_Runner([ + * 'queue' => $dl->fillQueue(), ... + * ]); + * $runner->runAllViaWeb(); + * + * == NOTE: Using subprocesses + * + * When upgrading extensions, you MUST provide a chance to reset the PHP process (loading new PHP files). + * + * We will assume that every task runs in a new PHP process. This is compatible with runAllViaWeb() not but runAll(). + * + * Headless clients (like `cv`) will need to use a suitable runner that spawns new subprocesses. + * + * == NOTE: Sequencing + * + * When you have multiple extensions to download/enable (and each may come with different start-state + * and version; and each may have differing versioned-dependencies)... then there is an interesting + * question about how to sequence/group the operations. + * + * Some operations target multiple ext's concurrently (like "rebuild" or "hook_upgrade" or "enable(keys=>A,B,C)"). + * It's nice to lean into this style ("fetch A+B+C" then "rebuild system" then "upgrade A+B+C") + * because it's a good facsimile of the behavior in SCM (git/gzr/svn)-based workflows. + * + * However, it's not perfect, and there may still be edge-cases where that doesn't work. I'm pessimistic + * that this class will be able to automatically form perfect+universal plans based only on a declared + * list of downloads. + * + * So if a problematic edge-case comes up, how could you resolve it? The caller can decide sequencing/batching. + * Compare: + * + * ## Ex 1: Download 'a' and 'b' in the same batch. They will be fetched, swapped, and rebuilt in tandem. + * $dl->addDownloads(['a' => ..., 'b' => ...]); + * + * ## Ex 2: Download 'a' and 'b' as separate batches. 'a' will be fully handled before 'b'. + * $dl->addDownloads(['a' => ...]); + * $dl->addDownloads(['b' => ...]); + * + * @package CRM + * @copyright CiviCRM LLC https://civicrm.org/licensing + */ +class PfQueueDownloader { + + const QUEUE_PREFIX = 'pf_ext_upgrade_'; + + /** + * Unique ID for this batch of downloads. + * + * @var string + * Ex: 20250607_abcd1234abcd1234 + */ + protected $upId; + + protected $queue; + + protected $cleanup; + + /** + * @var array + * Ex: [0 => ['type' => 'download', 'urls' => ['my.extension' => 'https://example/my.extension-1.0.zip']]] + * Ex: [0 => ['type' => 'enable', 'keys' => ['my.extension']]] + */ + protected $batches = []; + + /** + * @param bool $cleanup + * Whether to delete temporary files and backup files at the end. + * @param \CRM_Queue_Queue|null $queue + */ + public function __construct(bool $cleanup = TRUE, ?CRM_Queue_Queue $queue = NULL) { + $this->upId = (CRM_Utils_Time::date('Y-m-d') . '-' . CRM_Utils_String::createRandom(16, CRM_Utils_String::ALPHANUMERIC)); + $this->cleanup = $cleanup; + $this->queue = $queue ?: $this->createQueue(); + } + + public function createQueue(): CRM_Queue_Queue { + return Civi::queue(static::QUEUE_PREFIX . $this->upId, [ + 'type' => 'Sql', + 'runner' => 'task', + 'is_autorun' => FALSE, + 'retry_limit' => 0, + 'error' => 'abort', + 'reset' => TRUE, + ]); + } + + protected function getStagingPath(...$moreParts): string { + // The staging path is basically "{extensionsDir}/.civicrm-staging/{upId}". Note that: + // - This puts the staging files in the right filesystem (close to their final location). + // Thus, `rename()` can be used to rearrange folders. + // - `uploadDir` might be viable, but it's also at greater risk of getting auto-cleaned + // and being on separate filesystem. + + $baseDir = CRM_Extension_System::singleton()->getDefaultContainer()->baseDir; + $prefix = [ + rtrim($baseDir, DIRECTORY_SEPARATOR . '/'), + '.civicrm-staging', + $this->upId, + ]; + return implode('/', array_merge($prefix, $moreParts)); + } + + public function getTitle(): string { + return ts('Download and Install (ID: %1)', [1 => $this->upId]); + } + + /** + * Add a set of extensions to download and enable. + * + * @param array $downloads + * Ex: ['ext1' => 'https://example.com/ext1/releases/1.0.zip'] + * @param bool $autoApply + * TRUE if the downloader should execute the installation/upgrade routines + * FALSE if the downloader should only get the files and put them in place + * + * @return $this + */ + public function addDownloads(array $downloads, bool $autoApply = TRUE) { + $this->batches[] = ['type' => 'download', 'urls' => $downloads, 'autoApply' => $autoApply]; + return $this; + } + + /** + * Add a set of keys which should be enabled. (Use this if you -only- want to enable. If you are actually downloading, + * then use addDownloads().) + * + * @param array $keys + * Ex: ['my.ext1', 'my.ext2'] + * + * @return $this + */ + public function addEnable(array $keys) { + $this->batches[] = ['type' => 'enable', 'keys' => $keys]; + return $this; + } + + /** + * Take the list of pending updates (from addDownload, addEnable) + */ + public function fillQueue(): CRM_Queue_Queue { + $queue = $this->queue; + + // Store some metadata about what's going on. This may help with debugging. + CRM_Utils_File::createDir($this->getStagingPath(), 'exception'); + file_put_contents($this->getStagingPath('details.json'), json_encode([ + 'startTime' => CRM_Utils_Time::date('c'), + 'upId' => $this->upId, + 'queue' => $queue->getName(), + 'batches' => $this->batches, + 'statuses' => CRM_Extension_System::singleton()->getManager()->getStatuses(), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + foreach ($this->batches as $batch) { + switch ($batch['type']) { + case 'enable': + $queue->createItem(static::task(ts('Enable %1', [1 => $this->quotedList($batch['keys'])]), 'enable', [$batch['keys']])); + break; + + case 'download': + $downloads = $batch['urls']; + + // Download and extract zip files. This is I/O dependent (error-prone), so we do each as a separate (retriable) step. + foreach ($downloads as $ext => $url) { + $queue->createItem(static::task(ts('Fetch "%1" from "%2"', [1 => $ext, 2 => $url]), 'fetch', [$ext, $url])); + } + + // Verify all requirements with a single operation -- _before_ loading the new code. + // We won't be sensitive to (re)ordering of fetch-tasks, because we only care if the final set is coherent. + $queue->createItem(static::task(ts('Verify requirements'), 'preverify', [array_keys($downloads)])); + + // Swap-in new folders with a single operation. This should be similar to more sophisticated site-builder + // workflows. (f you manage a site in git, then "git pull" swaps all code at the same time.) This + // can't guarantee that all combinations of $downloads work, but at least they'll behave consistently. + $queue->createItem(static::task(ts('Swap folders'), 'swap', [array_keys($downloads)])); + + // The "swap" and "rebuild" must happen in separate steps. + if ($batch['autoApply']) { + $queue->createItem(static::task(ts('Rebuild system'), 'rebuild')); + } + + $statuses = CRM_Extension_System::singleton()->getManager()->getStatuses(); + $findByStatus = function(array $matchStatuses) use ($downloads, $statuses) { + return array_filter( + array_keys($downloads), + function($key) use ($statuses, $matchStatuses) { + return in_array($statuses[$key] ?? CRM_Extension_Manager::STATUS_UNINSTALLED, $matchStatuses, TRUE); + } + ); + }; + $needEnable = $findByStatus([ + CRM_Extension_Manager::STATUS_UNINSTALLED, + CRM_Extension_Manager::STATUS_DISABLED, + CRM_Extension_Manager::STATUS_DISABLED_MISSING, + ]); + $needUpgrade = $findByStatus([ + CRM_Extension_Manager::STATUS_INSTALLED, + CRM_Extension_Manager::STATUS_DISABLED, + CRM_Extension_Manager::STATUS_DISABLED_MISSING, + ]); + if ($batch['autoApply'] && $needEnable) { + $queue->createItem(static::task(ts('Enable %1', [1 => $this->quotedList($needEnable)]), 'enable', [$needEnable])); + } + if ($batch['autoApply'] && $needUpgrade) { + $queue->createItem(static::task(ts('Upgrade database'), 'upgradeDb')); + } + + break; + + } + } + + if ($this->cleanup) { + $queue->createItem( + static::task(ts('Cleanup workspace'), 'cleanup'), + ['weight' => 2000] + ); + } + + return $queue; + } + + private function quotedList(array $items) { + // This can at least adapt to quotes and guillemets... we should probably have some more general helpers for lists and conjunctions... + $template = ts('"%1"'); + return implode(', ', array_map(function($item) use ($template) { + return str_replace('%1', $item, $template); + }, $items)); + } + + /** + * Create a CRM_Queue_Task that executes on this class. + * + * @param string $title + * @param string $method + * @param array $args + * + * @return \CRM_Queue_Task + */ + protected function task(string $title, string $method, array $args = []): CRM_Queue_Task { + return new CRM_Queue_Task( + [PfQueueTasks::class, $method], + array_merge([$this->getStagingPath()], $args), + $title + ); + } + +} diff --git a/src/ExtensionPolyfill/PfQueueTasks.php b/src/ExtensionPolyfill/PfQueueTasks.php new file mode 100644 index 00000000..b8f4d4e8 --- /dev/null +++ b/src/ExtensionPolyfill/PfQueueTasks.php @@ -0,0 +1,155 @@ +getDownloader(); + if (!$downloader->fetch($url, $zipFile)) { + throw new CRM_Extension_Exception("Failed to download: $url"); + } + + $extractedZipPath = PfHelper::extractFiles($key, $zipFile, $tmpDir); + if (!$extractedZipPath) { + throw new CRM_Extension_Exception("Failed to extract: $zipFile"); + } + + if (!$downloader->validateFiles($key, $extractedZipPath)) { + throw new CRM_Extension_Exception("Failed to validate $extractedZipPath. Consult CiviCRM log for details."); + // FIXME: Might be nice to show errors immediately, but we've got bigger fish to fry right now. + } + + if (!rename($extractedZipPath, $stageDir)) { + throw new CRM_Extension_Exception("Failed to rename $extractedZipPath to $stageDir"); + } + + return TRUE; + } + + /** + * Scan the downloaded extensions and verify that their requirements are satisfied. + * This checks requirements as declared in the staging area. + * @throws \CRM_Core_Exception + */ + public static function preverify(CRM_Queue_TaskContext $ctx, string $stagingPath, array $keys): bool { + $infos = CRM_Extension_System::singleton()->getMapper()->getAllInfos(); + foreach ($keys as $key) { + $infos[$key] = CRM_Extension_Info::loadFromFile("$stagingPath/new/$key/" . CRM_Extension_Info::FILENAME); + } + + $errors = PfHelper::checkInstallRequirements($keys, $infos); + if (!empty($errors)) { + Civi::log()->error('Failed to verify requirements for new downloads in {path}', [ + 'path' => $stagingPath, + 'installKeys' => $keys, + 'errors' => $errors, + ]); + throw new CRM_Extension_Exception(implode("\n", array_merge( + ["Failed to verify requirements for new downloads in $stagingPath."], + array_column($errors, 'title'), + ["Consult CiviCRM log for details."], + ))); + } + + return TRUE; + } + + /** + * Take the extracted code (`stagingDir/new/{key}`) and put it into its final place. + * Move any old code to the backup (`stagingDir/old/{key}`). + * Delete the container-cache + * @throws \CRM_Core_Exception + */ + public static function swap(CRM_Queue_TaskContext $ctx, string $stagingPath, array $keys): bool { + PfHelper::createDir("$stagingPath/old"); + try { + foreach ($keys as $key) { + $tmpCodeDir = "$stagingPath/new/$key"; + $backupCodeDir = "$stagingPath/old/$key"; + + PfHelper::basicReplace($tmpCodeDir, $backupCodeDir); + // What happens when you call replace(.., refresh: false)? Varies by type: + // - For report/search/payment-extensions, it runs the uninstallation/reinstallation routines. + // - For module-extensions, it swaps the folders and clears the class-index. + + // Arguably, for DownloadQueue, we should only clear class-index after all code is swapped, + // but it's messier to write that patch, and it's not clear if it's needed. + } + } finally { + // Delete `CachedCiviContainer.*.php`, `CachedExtLoader.*.php`, and similar. + $config = CRM_Core_Config::singleton(); + $config->cleanup(1); + CRM_Core_Config::clearDBCache(); + // $config->cleanupCaches(FALSE); + } + + return TRUE; + } + + /** + * @param \CRM_Queue_TaskContext $ctx + * @return bool + * @throws \Exception + */ + public static function rebuild(CRM_Queue_TaskContext $ctx): bool { + CRM_Core_Invoke::rebuildMenuAndCaches(TRUE, FALSE); + return TRUE; + } + + /** + * Scan the downloaded extensions and verify that their requirements are satisfied. + */ + public static function enable(CRM_Queue_TaskContext $ctx, string $stagingPath, array $keys): bool { + CRM_Extension_System::singleton()->getManager()->enable($keys); + return TRUE; + } + + public static function upgradeDb(CRM_Queue_TaskContext $ctx): bool { + if (CRM_Extension_Upgrades::hasPending()) { + CRM_Extension_Upgrades::fillQueue($ctx->queue); + } + return TRUE; + } + + /** + * @param \CRM_Queue_TaskContext $ctx + * @param string $stagingPath + * @return bool + * @throws \CRM_Core_Exception + */ + public static function cleanup(CRM_Queue_TaskContext $ctx, string $stagingPath): bool { + CRM_Utils_File::cleanDir($stagingPath, TRUE, FALSE); + return TRUE; + } + +} From 812952f1af1f1e794079bdd02bd88732cea08688 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 9 Apr 2025 20:31:58 -0700 Subject: [PATCH 86/90] If we call cv.phar recursively, do NOT re-print the box requirements check The output gets quite annoying with (eg) `cv dl -vv` (where every task in the queue gets run in a subprocess -- and winds up reprinting this message). --- bin/cv | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bin/cv b/bin/cv index 0ed40ad0..e9995c0a 100755 --- a/bin/cv +++ b/bin/cv @@ -1,6 +1,9 @@ #!/usr/bin/env php Date: Thu, 10 Apr 2025 16:26:30 +0200 Subject: [PATCH 87/90] Show the config file location of cv.json with the command vars:show --- lib/src/SiteConfigReader.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/SiteConfigReader.php b/lib/src/SiteConfigReader.php index fdc11b87..8bbbe6a9 100644 --- a/lib/src/SiteConfigReader.php +++ b/lib/src/SiteConfigReader.php @@ -66,6 +66,7 @@ public function readHomeConfig() { $config = Config::read(); $this->cache['home'] = isset($config['sites'][$this->settingsFile]) ? $config['sites'][$this->settingsFile] : array(); + $this->cache['home']['CONFIG_FILE'] = Config::getFileName(); } return $this->cache['home']; } From 71be61a4c149e9ac9b8ac93f48bae21192826346 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Thu, 10 Apr 2025 10:22:25 -0700 Subject: [PATCH 88/90] Add test for `Top` helper --- tests/CvTestTrait.php | 10 +++++++++- tests/TopHelperTest.php | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tests/TopHelperTest.php diff --git a/tests/CvTestTrait.php b/tests/CvTestTrait.php index 5185357c..b3643bde 100644 --- a/tests/CvTestTrait.php +++ b/tests/CvTestTrait.php @@ -13,7 +13,7 @@ trait CvTestTrait { * @return \Symfony\Component\Process\Process */ protected function cv($command) { - $cvPath = getenv('CV_TEST_BINARY') ?: dirname(__DIR__) . '/bin/cv'; + $cvPath = $this->getCvPath(); $process = \Symfony\Component\Process\Process::fromShellCommandline("{$cvPath} $command"); return $process; } @@ -51,4 +51,12 @@ protected function cvJsonOk($cmd) { return json_decode($p->getOutput(), 1); } + private function getCvPath(): string { + return getenv('CV_TEST_BINARY') ?: dirname(__DIR__) . '/bin/cv'; + } + + protected function isCvPharTest(): bool { + return (bool) preg_match(';\.phar$;', $this->getCvPath()); + } + } diff --git a/tests/TopHelperTest.php b/tests/TopHelperTest.php new file mode 100644 index 00000000..82cec822 --- /dev/null +++ b/tests/TopHelperTest.php @@ -0,0 +1,39 @@ +isCvPharTest()) { + $exprs = [ + 'Cvphar\Fruit\Apple' => '\Fruit\Apple', + '\Cvphar\Fruit\Banana' => '\Fruit\Banana', + 'Fruit\Cherry' => '\Fruit\Cherry', + '\Fruit\Date' => '\Fruit\Date', + ]; + } + else { + $exprs = [ + 'Fruit\Apple' => '\Fruit\Apple', + '\Fruit\Banana' => '\Fruit\Banana', + ]; + } + + foreach ($exprs as $input => $expected) { + $p = Process::runOk($this->cv("ev 'return \Civi\Cv\Top::symbol(getenv(\"SYMBOL\"));'") + ->setEnv(['SYMBOL' => $input])); + $actual = json_decode($p->getOutput()); + $this->assertEquals($expected, $actual, "Input ($input) should yield value ($expected)."); + } + } + +} From 0988cccb734740f0ebd03809de18648e9e3e1437 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Fri, 11 Apr 2025 17:38:27 -0700 Subject: [PATCH 89/90] PfQueueTasks - If .civicrm-staging/ is empty, then remove it --- src/ExtensionPolyfill/PfQueueTasks.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ExtensionPolyfill/PfQueueTasks.php b/src/ExtensionPolyfill/PfQueueTasks.php index b8f4d4e8..91570003 100644 --- a/src/ExtensionPolyfill/PfQueueTasks.php +++ b/src/ExtensionPolyfill/PfQueueTasks.php @@ -149,6 +149,11 @@ public static function upgradeDb(CRM_Queue_TaskContext $ctx): bool { */ public static function cleanup(CRM_Queue_TaskContext $ctx, string $stagingPath): bool { CRM_Utils_File::cleanDir($stagingPath, TRUE, FALSE); + $parent = dirname($stagingPath); + $siblings = preg_grep('/^\.\.?$/', scandir($parent), PREG_GREP_INVERT); + if (empty($siblings)) { + rmdir($parent); + } return TRUE; } From bb30a1a8c6a67ca2807217fb7dc2367d86324a61 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Sun, 4 May 2025 11:51:04 -0700 Subject: [PATCH 90/90] PHP 8.4 - Fix warning about E_STRICT Fix warning: Deprecated: Constant E_STRICT is deprecated in ...lib/src/ErrorHandler.php Reportedly, E_STRICT: * Is meaningful (from php-src.git POV) in 7.x * Sopped being meaningful (from php-src.git POV) in 8.x * Started throwing deprecation warnings in 8.4 It's unclear if there might be significance for contrib in the 8.x era. --- lib/src/ErrorHandler.php | 38 +++++++++++++++++++++----------------- lib/src/Util/BootTrait.php | 4 +++- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/lib/src/ErrorHandler.php b/lib/src/ErrorHandler.php index b4fb59ca..dccb280e 100644 --- a/lib/src/ErrorHandler.php +++ b/lib/src/ErrorHandler.php @@ -72,23 +72,27 @@ public static function setRenderer(?callable $renderer): void { } protected static function getErrorTypes(): array { - return [ - E_ERROR => 'PHP Error', - E_WARNING => 'PHP Warning', - E_PARSE => 'PHP Parse Error', - E_NOTICE => 'PHP Notice', - E_CORE_ERROR => 'PHP Core Error', - E_CORE_WARNING => 'PHP Core Warning', - E_COMPILE_ERROR => 'PHP Compile Error', - E_COMPILE_WARNING => 'PHP Compile Warning', - E_USER_ERROR => 'PHP User Error', - E_USER_WARNING => 'PHP User Warning', - E_USER_NOTICE => 'PHP User Notice', - E_STRICT => 'PHP Strict Warning', - E_RECOVERABLE_ERROR => 'PHP Recoverable Fatal Error', - E_DEPRECATED => 'PHP Deprecation', - E_USER_DEPRECATED => 'PHP User Deprecation', - ]; + return array_merge( + [ + E_ERROR => 'PHP Error', + E_WARNING => 'PHP Warning', + E_PARSE => 'PHP Parse Error', + E_NOTICE => 'PHP Notice', + E_CORE_ERROR => 'PHP Core Error', + E_CORE_WARNING => 'PHP Core Warning', + E_COMPILE_ERROR => 'PHP Compile Error', + E_COMPILE_WARNING => 'PHP Compile Warning', + E_USER_ERROR => 'PHP User Error', + E_USER_WARNING => 'PHP User Warning', + E_USER_NOTICE => 'PHP User Notice', + E_RECOVERABLE_ERROR => 'PHP Recoverable Fatal Error', + E_DEPRECATED => 'PHP Deprecation', + E_USER_DEPRECATED => 'PHP User Deprecation', + ], + version_compare(phpversion(), '8.4', '<') ? [constant('E_STRICT') => 'PHP Strict Warning'] : [] + // https://wiki.php.net/rfc/deprecations_php_8_4#remove_e_strict_error_level_and_deprecate_e_strict_constant + // In theory, once cv shifts to 8.x only, we can simplify this. + ); } } diff --git a/lib/src/Util/BootTrait.php b/lib/src/Util/BootTrait.php index 1bcbd5b5..0814f1b7 100644 --- a/lib/src/Util/BootTrait.php +++ b/lib/src/Util/BootTrait.php @@ -330,7 +330,9 @@ protected function setupErrorHandling(OutputInterface $output) { $this->bootLogger($output)->debug('Attempting to set verbose error reporting'); // standard php debug chat settings - error_reporting(E_ALL | E_STRICT); + error_reporting(E_ALL | (version_compare(phpversion(), '8.4', '<') ? E_STRICT : 0)); + // https://wiki.php.net/rfc/deprecations_php_8_4#remove_e_strict_error_level_and_deprecate_e_strict_constant + // In theory, once cv shifts to 8.x only, we can simplify this. ini_set('display_errors', 'stderr'); ini_set('display_startup_errors', TRUE); }