From 0efcd608109a71d13159d704392c45bb61596b1a Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 4 Mar 2024 22:37:33 -0500 Subject: [PATCH 01/18] (REF) Application - Towards filtering of $input and $output objects --- bin/cv | 2 +- src/Application.php | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/bin/cv b/bin/cv index 59c5eb8b..a1015f0d 100755 --- a/bin/cv +++ b/bin/cv @@ -26,4 +26,4 @@ foreach ($autoloaders as $autoloader) { if (!$found) { die("Failed to find autoloader"); } -\Civi\Cv\Application::main(__DIR__); +\Civi\Cv\Application::main(__DIR__, $argv); diff --git a/src/Application.php b/src/Application.php index e56f3fb6..5d2b872d 100644 --- a/src/Application.php +++ b/src/Application.php @@ -2,8 +2,10 @@ namespace Civi\Cv; use LesserEvil\ShellVerbosityIsEvil; +use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -39,12 +41,15 @@ public static function version(): ?string { /** * Primary entry point for execution of the standalone command. */ - public static function main($binDir) { + public static function main($binDir, array $argv) { $application = new Application('cv', static::version() ?? 'UNKNOWN'); + $input = new ArgvInput($argv); + $output = new ConsoleOutput(); + $application->setAutoExit(FALSE); ErrorHandler::pushHandler(); - $result = $application->run(); + $result = $application->run($input, $output); ErrorHandler::popHandler(); exit($result); } From 95fefaddb5ffbee99a5714fdf377924fa817d1b8 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 5 Mar 2024 00:24:00 -0500 Subject: [PATCH 02/18] Add plugin subsystem This is heavily based on the loco's plugin subsystem. The main adaptations are: * Change class names. * Put the code in `cv-lib` so that affiliate commands (`coworker`, `civix`) can use the same plugin subsystem. * Don't use Symfony EventDispatcher. This makes it easier to include in `cv-lib`. --- doc/plugins.md | 52 +++++++++++++ lib/src/Cv.php | 77 +++++++++++++++++++ lib/src/CvDispatcher.php | 52 +++++++++++++ lib/src/CvEvent.php | 149 +++++++++++++++++++++++++++++++++++++ lib/src/CvPlugins.php | 90 ++++++++++++++++++++++ src/Application.php | 7 +- tests/CvDispatcherTest.php | 102 +++++++++++++++++++++++++ 7 files changed, 528 insertions(+), 1 deletion(-) create mode 100644 doc/plugins.md create mode 100644 lib/src/Cv.php create mode 100644 lib/src/CvDispatcher.php create mode 100644 lib/src/CvEvent.php create mode 100644 lib/src/CvPlugins.php create mode 100644 tests/CvDispatcherTest.php diff --git a/doc/plugins.md b/doc/plugins.md new file mode 100644 index 00000000..5bd44fe3 --- /dev/null +++ b/doc/plugins.md @@ -0,0 +1,52 @@ +# Plugins + +Cv plugins are PHP files which register event listeners. + +## Example: Add command + +```php +// FILE: /etc/cv/plugin/hello-command.php +use Civi\Cv\Cv; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +if (empty($CV_PLUGIN['protocol']) || $CV_PLUGIN['protocol'] > 1) die("Expect CV_PLUGIN API v1"); + +Cv::dispatcher()->addListener('cv.app.commands', function($e) { + $e['commands'][] = new class extends \Symfony\Component\Console\Command\Command { + protected function configure() { + $this->setName('hello')->setDescription('Say a greeting'); + } + protected function execute(InputInterface $input, OutputInterface $output) { + $output->writeln('Hello there!'); + } + }; +}); +``` + +## Plugin loading + +*Global plugins* are loaded from the `CV_PLUGIN_PATH`. All `*.php` files in +`CV_PLUGIN_PATH` will be loaded automatically during startup. Plugins are +deduped by name (with earlier folders having higher precedence). + +If otherwise unspecified, the default value of `CV_PLUGIN_PATH` is: + +```bash +CV_PLUGIN_PATH=$HOME/.config/cv/plugin/:/etc/cv/plugin:/usr/share/cv/plugin:/usr/local/share/cv/plugin +``` + + + +## Events + +* `cv.app.boot` (*global-only*): Fires immediately when the application starts +* `cv.app.run` (*global-only*): Fires when the application begins executing a command +* `cv.app.commands` (*global-only*): Fires when the application builds a list of available commands + * __Argument__: `$e['commands`]`: alterable list of commands diff --git a/lib/src/Cv.php b/lib/src/Cv.php new file mode 100644 index 00000000..9e56a286 --- /dev/null +++ b/lib/src/Cv.php @@ -0,0 +1,77 @@ +dispatch($event, $eventName); + return $event->getArguments(); + } + + /** + * Get the plugin manager. + * + * @return \Civi\Cv\CvPlugins + */ + public static function plugins(): CvPlugins { + if (!isset(self::$instances['plugins'])) { + self::$instances['plugins'] = new CvPlugins(); + } + return self::$instances['plugins']; + } + +} diff --git a/lib/src/CvDispatcher.php b/lib/src/CvDispatcher.php new file mode 100644 index 00000000..08040b8f --- /dev/null +++ b/lib/src/CvDispatcher.php @@ -0,0 +1,52 @@ +listeners[$eventName])) { + return $event; + } + + ksort($this->listeners[$eventName], SORT_NUMERIC); + foreach ($this->listeners[$eventName] as $listeners) { + foreach ($listeners as $listener) { + $listener($event); + } + } + + return $event; + } + + public function addListener(string $eventName, $callback, int $priority = 0): void { + $id = $this->getCallbackId($callback); + $this->listeners[$eventName][$priority][$id] = $callback; + } + + public function removeListener(string $eventName, $callback) { + $id = $this->getCallbackId($callback); + foreach ($this->listeners[$eventName] as &$listeners) { + unset($listeners[$id]); + } + } + + /** + * @param $callback + * @return string + */ + protected function getCallbackId($callback): string { + if (is_string($callback)) { + return $callback; + } + elseif (is_array($callback)) { + return implode('::', $callback); + } + else { + return spl_object_hash($callback); + } + } + +} diff --git a/lib/src/CvEvent.php b/lib/src/CvEvent.php new file mode 100644 index 00000000..98a521ae --- /dev/null +++ b/lib/src/CvEvent.php @@ -0,0 +1,149 @@ +arguments = $arguments; + } + + /** + * Get argument by key. + * + * @param string $key Key + * @return mixed Contents of array key + * + * @throws \InvalidArgumentException if key is not found + */ + public function getArgument($key) { + if ($this->hasArgument($key)) { + return $this->arguments[$key]; + } + + throw new \InvalidArgumentException(sprintf('Argument "%s" not found.', $key)); + } + + /** + * Add argument to event. + * + * @param string $key Argument name + * @param mixed $value Value + * + * @return $this + */ + public function setArgument($key, $value) { + $this->arguments[$key] = $value; + + return $this; + } + + /** + * Getter for all arguments. + * + * @return array + */ + public function getArguments() { + return $this->arguments; + } + + /** + * Set args property. + * + * @param array $args Arguments + * + * @return $this + */ + public function setArguments(array $args = []) { + $this->arguments = $args; + + return $this; + } + + /** + * Has argument. + * + * @param string $key Key of arguments array + * + * @return bool + */ + public function hasArgument($key) { + return \array_key_exists($key, $this->arguments); + } + + /** + * IteratorAggregate for iterating over the object like an array. + * + * @return \ArrayIterator + */ + public function getIterator(): \Traversable { + return new \ArrayIterator($this->arguments); + } + + /** + * ArrayAccess for argument getter. + * + * @param string $key Array key + * @return mixed + * @throws \InvalidArgumentException if key does not exist in $this->args + */ + #[\ReturnTypeWillChange] + public function &offsetGet($offset) { + return $this->arguments[$offset]; + } + + /** + * ArrayAccess for argument setter. + * + * @param string $offset Array key to set + * @param mixed $value Value + */ + public function offsetSet($offset, $value): void { + $this->setArgument($offset, $value); + } + + /** + * ArrayAccess for unset argument. + * + * @param string $offset Array key + */ + public function offsetUnset($offset): void { + if ($this->hasArgument($offset)) { + unset($this->arguments[$offset]); + } + } + + /** + * ArrayAccess has argument. + * + * @param string $offset Array key + * @return bool + */ + public function offsetExists($offset): bool { + return $this->hasArgument($offset); + } + + public function isPropagationStopped(): bool { + return $this->propagationStopped; + } + + public function setPropagationStopped(bool $propagationStopped): void { + $this->propagationStopped = $propagationStopped; + } + +} diff --git a/lib/src/CvPlugins.php b/lib/src/CvPlugins.php new file mode 100644 index 00000000..f1af8a94 --- /dev/null +++ b/lib/src/CvPlugins.php @@ -0,0 +1,90 @@ +paths = explode(PATH_SEPARATOR, getenv('CV_PLUGIN_PATH')); + } + else { + $this->paths = ['/etc/cv/plugin', '/usr/local/share/cv/plugin', '/usr/share/cv/plugin']; + if (getenv('HOME')) { + array_unshift($this->paths, getenv('HOME') . '/.cv/plugin'); + } + } + + // Always load internal plugins + $this->paths[] = dirname(__DIR__) . '/plugin'; + + $this->plugins = []; + foreach ($this->paths as $path) { + if (file_exists($path) && is_dir($path)) { + foreach ((array) glob("$path/*.php") as $file) { + $pluginName = preg_replace(';(\d+-)?(.*)(@\w+)?\.php;', '\\2', basename($file)); + if ($pluginName === basename($file)) { + throw new \RuntimeException("Malformed plugin name: $file"); + } + if (!isset($this->plugins[$pluginName])) { + $this->plugins[$pluginName] = $file; + } + else { + fprintf(STDERR, "WARNING: Plugin %s has multiple definitions (%s, %s)\n", $file, $this->plugins[$pluginName]); + } + } + } + } + + ksort($this->plugins); + foreach ($this->plugins as $pluginName => $pluginFile) { + // FIXME: Refactor so that you can add more plugins post-boot `load("/some/glob*.php")` + $this->load([ + 'protocol' => 1, + 'name' => $pluginName, + 'file' => $pluginFile, + ]); + } + } + + /** + * @param array $CV_PLUGIN + * Description of the plugin being loaded. + * Keys: + * - version: Protocol version (ex: "1") + * - name: Basenemae of the plugin (eg `hello.php`) + * - file: Logic filename (eg `/etc/cv/plugin/hello.php`) + * @return void + */ + protected function load(array $CV_PLUGIN) { + include $CV_PLUGIN['file']; + } + + /** + * @return string[] + */ + public function getPaths(): array { + return $this->paths; + } + + /** + * @return array + */ + public function getPlugins(): array { + return $this->plugins; + } + +} diff --git a/src/Application.php b/src/Application.php index 5d2b872d..57d69f4c 100644 --- a/src/Application.php +++ b/src/Application.php @@ -42,7 +42,10 @@ public static function version(): ?string { * Primary entry point for execution of the standalone command. */ public static function main($binDir, array $argv) { - $application = new Application('cv', static::version() ?? 'UNKNOWN'); + Cv::plugins()->init(); + $application = Cv::filter('cv.app.boot', [ + 'app' => new Application('cv', static::version() ?? 'UNKNOWN'), + ])['app']; $input = new ArgvInput($argv); $output = new ConsoleOutput(); @@ -91,6 +94,7 @@ public function doRun(InputInterface $input, OutputInterface $output) { throw new \RuntimeException("Failed to use directory specified, $workingDir as working directory."); } } + Cv::filter('cv.app.run', []); return parent::doRun($input, $output); } @@ -141,6 +145,7 @@ public function createCommands($context = 'default') { $commands[] = new \Civi\Cv\Command\CoreUninstallCommand(); $commands[] = new \Stecman\Component\Symfony\Console\BashCompletion\CompletionCommand(); } + $commands = Cv::filter('cv.app.commands', ['commands' => $commands])['commands']; return $commands; } diff --git a/tests/CvDispatcherTest.php b/tests/CvDispatcherTest.php new file mode 100644 index 00000000..21c81b03 --- /dev/null +++ b/tests/CvDispatcherTest.php @@ -0,0 +1,102 @@ +addListener('foo', function ($event) { + static::addLog('foo'); + }); + $d->addListener('bar', function ($event) { + static::addLog('unrelated'); + }); + $d->dispatch(new CvEvent(['data' => 'yoyo']), 'foo')->getArguments(); + $this->assertEquals(['foo'], static::$log); + } + + public function testCallbackTypes() { + $d = new CvDispatcher(); + $d->addListener('foo', function ($event) { + static::addLog("foo one data=" . $event['data']); + }); + $d->addListener('foo', __NAMESPACE__ . '\\cvdispatcher_global_func'); + $d->addListener('foo', [__CLASS__, 'addFooThree']); + + $r = $d->dispatch(new CvEvent(['data' => 'yoyo']), 'foo')->getArguments(); + $this->assertEquals('yoyo', $r['data']); + $this->assertEquals([ + 'foo one data=yoyo', + 'called Civi\\Cv\\cvdispatcher_global_func', + 'foo three data=yoyo', + ], static::$log); + } + + public function testAlter() { + $d = new CvDispatcher(); + $d->addListener('foo', function ($event) { + $event['data'] .= ' first'; + }); + $d->addListener('foo', function ($event) { + $event['data'] .= ' second'; + }); + $r = $d->dispatch(new CvEvent(['data' => 'seed']), 'foo')->getArguments(); + $this->assertEquals('seed first second', $r['data']); + } + + public function testPriority() { + $d = new CvDispatcher(); + $d->addListener('foo', function ($event) { + static::addLog('foo.3.1'); + }, 3); + $d->addListener('foo', function ($event) { + static::addLog('foo.-200.1'); + }, -200); + $d->addListener('foo', function ($event) { + static::addLog('foo.1.1'); + }, 1); + $d->addListener('foo', function ($event) { + static::addLog('foo.2.1'); + }, 2); + $d->addListener('foo', function ($event) { + static::addLog('foo.1.2'); + }, 1); + $d->addListener('foo', function ($event) { + static::addLog('foo.1.3'); + }, 1); + $d->dispatch(new CvEvent([]), 'foo'); + $this->assertEquals([ + 'foo.-200.1', + 'foo.1.1', + 'foo.1.2', + 'foo.1.3', + 'foo.2.1', + 'foo.3.1', + ], static::$log); + } + + public static function addLog(string $message) { + static::$log[] = $message; + } + + public static function addFooThree($event) { + static::addLog("foo three data=" . $event['data']); + } + +} From 3b1b10d31ebf40d158048dac90833201b97e70fe Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 5 Mar 2024 01:10:51 -0500 Subject: [PATCH 03/18] (REF) Extract BaseApplication --- bin/cv | 2 +- lib/src/BaseApplication.php | 91 +++++++++++++++++++++++++++++++++++++ src/Application.php | 77 +------------------------------ 3 files changed, 93 insertions(+), 77 deletions(-) create mode 100644 lib/src/BaseApplication.php diff --git a/bin/cv b/bin/cv index a1015f0d..e9422a19 100755 --- a/bin/cv +++ b/bin/cv @@ -26,4 +26,4 @@ foreach ($autoloaders as $autoloader) { if (!$found) { die("Failed to find autoloader"); } -\Civi\Cv\Application::main(__DIR__, $argv); +\Civi\Cv\Application::main('cv', __DIR__, $argv); diff --git a/lib/src/BaseApplication.php b/lib/src/BaseApplication.php new file mode 100644 index 00000000..43add52b --- /dev/null +++ b/lib/src/BaseApplication.php @@ -0,0 +1,91 @@ +init(); + $application = Cv::filter("${name}.app.boot", [ + 'app' => new $class($name, static::version() ?? 'UNKNOWN'), + ])['app']; + + $input = new ArgvInput($argv); + $output = new ConsoleOutput(); + + $application->setAutoExit(FALSE); + ErrorHandler::pushHandler(); + try { + $result = $application->run($input, $output); + } + finally { + ErrorHandler::popHandler(); + } + exit($result); + } + + public function __construct($name = 'UNKNOWN', $version = 'UNKNOWN') { + parent::__construct($name, $version); + $this->setCatchExceptions(TRUE); + + $commands = Cv::filter($this->getName() . '.app.commands', [ + 'commands' => $this->createCommands(), + ])['commands']; + $this->addCommands($commands); + } + + /** + * {@inheritdoc} + */ + 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')); + return $definition; + } + + /** + * {@inheritdoc} + */ + public function doRun(InputInterface $input, OutputInterface $output) { + ErrorHandler::setRenderer(function($e) use ($output) { + if ($output instanceof ConsoleOutputInterface) { + $this->renderThrowable($e, $output->getErrorOutput()); + } + else { + $this->renderThrowable($e, $output); + } + }); + + $workingDir = $input->getParameterOption(array('--cwd')); + if (FALSE !== $workingDir && '' !== $workingDir) { + if (!is_dir($workingDir)) { + throw new \RuntimeException("Invalid working directory specified, $workingDir does not exist."); + } + if (!chdir($workingDir)) { + throw new \RuntimeException("Failed to use directory specified, $workingDir as working directory."); + } + } + Cv::filter($this->getName() . '.app.run', []); + return parent::doRun($input, $output); + } + + protected function configureIO(InputInterface $input, OutputInterface $output) { + ShellVerbosityIsEvil::doWithoutEvil(function() use ($input, $output) { + parent::configureIO($input, $output); + }); + } + +} diff --git a/src/Application.php b/src/Application.php index 57d69f4c..363a3116 100644 --- a/src/Application.php +++ b/src/Application.php @@ -1,15 +1,7 @@ 'service', @@ -38,66 +30,6 @@ public static function version(): ?string { return NULL; } - /** - * Primary entry point for execution of the standalone command. - */ - public static function main($binDir, array $argv) { - Cv::plugins()->init(); - $application = Cv::filter('cv.app.boot', [ - 'app' => new Application('cv', static::version() ?? 'UNKNOWN'), - ])['app']; - - $input = new ArgvInput($argv); - $output = new ConsoleOutput(); - - $application->setAutoExit(FALSE); - ErrorHandler::pushHandler(); - $result = $application->run($input, $output); - ErrorHandler::popHandler(); - exit($result); - } - - public function __construct($name = 'UNKNOWN', $version = 'UNKNOWN') { - parent::__construct($name, $version); - $this->setCatchExceptions(TRUE); - $this->addCommands($this->createCommands()); - } - - /** - * {@inheritdoc} - */ - protected function getDefaultInputDefinition() { - $definition = parent::getDefaultInputDefinition(); - $definition->addOption(new InputOption('cwd', NULL, InputOption::VALUE_REQUIRED, 'If specified, use the given directory as working directory.')); - return $definition; - } - - /** - * {@inheritdoc} - */ - public function doRun(InputInterface $input, OutputInterface $output) { - ErrorHandler::setRenderer(function($e) use ($output) { - if ($output instanceof ConsoleOutputInterface) { - $this->renderThrowable($e, $output->getErrorOutput()); - } - else { - $this->renderThrowable($e, $output); - } - }); - - $workingDir = $input->getParameterOption(array('--cwd')); - if (FALSE !== $workingDir && '' !== $workingDir) { - if (!is_dir($workingDir)) { - throw new \RuntimeException("Invalid working directory specified, $workingDir does not exist."); - } - if (!chdir($workingDir)) { - throw new \RuntimeException("Failed to use directory specified, $workingDir as working directory."); - } - } - Cv::filter('cv.app.run', []); - return parent::doRun($input, $output); - } - /** * Construct command objects * @@ -145,16 +77,9 @@ public function createCommands($context = 'default') { $commands[] = new \Civi\Cv\Command\CoreUninstallCommand(); $commands[] = new \Stecman\Component\Symfony\Console\BashCompletion\CompletionCommand(); } - $commands = Cv::filter('cv.app.commands', ['commands' => $commands])['commands']; return $commands; } - protected function configureIO(InputInterface $input, OutputInterface $output) { - ShellVerbosityIsEvil::doWithoutEvil(function() use ($input, $output) { - parent::configureIO($input, $output); - }); - } - public function find($name) { if (isset($this->deprecatedAliases[$name])) { fprintf(STDERR, "WARNING: Subcommand \"%s\" has been renamed to \"%s\". In the future, the old name may stop working.\n\n", $name, $this->deprecatedAliases[$name]); From 54fd85bc02e15429aeb620ff796f30eb27dc8ec3 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 5 Mar 2024 01:12:09 -0500 Subject: [PATCH 04/18] bin/cv - PHP version check looks out-dated... --- bin/cv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/cv b/bin/cv index e9422a19..1fe7d44e 100755 --- a/bin/cv +++ b/bin/cv @@ -6,8 +6,8 @@ if (PHP_SAPI !== 'cli') { printf("TIP: In a typical shell environment, the \"php\" command should execute php-cli - not php-cgi or similar.\n"); exit(1); } -if (version_compare(PHP_VERSION, '5.4', '<')) { - echo "cv requires PHP 5.4+\n"; +if (version_compare(PHP_VERSION, '7.3', '<')) { + echo "cv requires PHP 7.3+\n"; exit(2); } $found = 0; From 10e91972712baf1bb60b1388500cc98519411133 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 5 Mar 2024 01:24:36 -0500 Subject: [PATCH 05/18] Cv::filter - Allow event wildcard --- lib/src/Cv.php | 5 +++- lib/src/CvDispatcher.php | 40 +++++++++++++++++++++++--------- tests/CvDispatcherTest.php | 47 +++++++++++++++++++++++--------------- 3 files changed, 61 insertions(+), 31 deletions(-) diff --git a/lib/src/Cv.php b/lib/src/Cv.php index 9e56a286..2f6c172f 100644 --- a/lib/src/Cv.php +++ b/lib/src/Cv.php @@ -51,12 +51,15 @@ public static function dispatcher() { * Filter a set of data through an event. * * @param string $eventName + * Ex: "cv.app.run" + * Note: This will dispatch listeners for both "cv.app.run" and "*.app.run". * @param array $data * Open-ended set of data. + * * @return array * Filtered $data */ - public static function filter(string $eventName, array $data) { + public static function filter($eventName, array $data) { $event = new CvEvent($data); self::dispatcher()->dispatch($event, $eventName); return $event->getArguments(); diff --git a/lib/src/CvDispatcher.php b/lib/src/CvDispatcher.php index 08040b8f..73161f99 100644 --- a/lib/src/CvDispatcher.php +++ b/lib/src/CvDispatcher.php @@ -6,31 +6,49 @@ class CvDispatcher { protected $listeners = []; - public function dispatch($event, string $eventName = NULL) { - if (!isset($this->listeners[$eventName])) { - return $event; + /** + * @param object $event + * @param string|null $eventName + * @return object + */ + public function dispatch($event, $eventName = NULL) { + $activeListeners = []; + + if ($eventName === NULL) { + $eventName = get_class($event); + } + $eventNames = [$eventName, preg_replace(';^\w+\.;', '*.', $eventName)]; + foreach ($eventNames as $name) { + if (isset($this->listeners[$name])) { + $activeListeners = array_merge($activeListeners, $this->listeners[$name]); + } } - ksort($this->listeners[$eventName], SORT_NUMERIC); - foreach ($this->listeners[$eventName] as $listeners) { - foreach ($listeners as $listener) { - $listener($event); + usort($activeListeners, function ($a, $b) { + if ($a['priority'] !== $b['priority']) { + return $a['priority'] - $b['priority']; + } + else { + return $a['natPriority'] - $b['natPriority']; } + }); + foreach ($activeListeners as $listener) { + call_user_func($listener['callback'], $event); } return $event; } public function addListener(string $eventName, $callback, int $priority = 0): void { + static $natPriority = 0; + $natPriority++; $id = $this->getCallbackId($callback); - $this->listeners[$eventName][$priority][$id] = $callback; + $this->listeners[$eventName][$id] = ['callback' => $callback, 'priority' => $priority, 'natPriority' => $natPriority]; } public function removeListener(string $eventName, $callback) { $id = $this->getCallbackId($callback); - foreach ($this->listeners[$eventName] as &$listeners) { - unset($listeners[$id]); - } + unset($this->listeners[$eventName][$id]); } /** diff --git a/tests/CvDispatcherTest.php b/tests/CvDispatcherTest.php index 21c81b03..edfeb5a2 100644 --- a/tests/CvDispatcherTest.php +++ b/tests/CvDispatcherTest.php @@ -21,25 +21,25 @@ protected function setUp(): void { public function testIgnoreUnrelated() { $d = new CvDispatcher(); - $d->addListener('foo', function ($event) { - static::addLog('foo'); + $d->addListener('myapp.foo', function ($event) { + static::addLog('myapp.foo'); }); - $d->addListener('bar', function ($event) { + $d->addListener('myapp.bar', function ($event) { static::addLog('unrelated'); }); - $d->dispatch(new CvEvent(['data' => 'yoyo']), 'foo')->getArguments(); - $this->assertEquals(['foo'], static::$log); + $d->dispatch(new CvEvent(['data' => 'yoyo']), 'myapp.foo')->getArguments(); + $this->assertEquals(['myapp.foo'], static::$log); } public function testCallbackTypes() { $d = new CvDispatcher(); - $d->addListener('foo', function ($event) { + $d->addListener('myapp.foo', function ($event) { static::addLog("foo one data=" . $event['data']); }); - $d->addListener('foo', __NAMESPACE__ . '\\cvdispatcher_global_func'); - $d->addListener('foo', [__CLASS__, 'addFooThree']); + $d->addListener('myapp.foo', __NAMESPACE__ . '\\cvdispatcher_global_func'); + $d->addListener('myapp.foo', [__CLASS__, 'addFooThree']); - $r = $d->dispatch(new CvEvent(['data' => 'yoyo']), 'foo')->getArguments(); + $r = $d->dispatch(new CvEvent(['data' => 'yoyo']), 'myapp.foo')->getArguments(); $this->assertEquals('yoyo', $r['data']); $this->assertEquals([ 'foo one data=yoyo', @@ -50,43 +50,52 @@ public function testCallbackTypes() { public function testAlter() { $d = new CvDispatcher(); - $d->addListener('foo', function ($event) { + $d->addListener('myapp.foo', function ($event) { $event['data'] .= ' first'; }); - $d->addListener('foo', function ($event) { + $d->addListener('myapp.foo', function ($event) { $event['data'] .= ' second'; }); - $r = $d->dispatch(new CvEvent(['data' => 'seed']), 'foo')->getArguments(); + $r = $d->dispatch(new CvEvent(['data' => 'seed']), 'myapp.foo')->getArguments(); $this->assertEquals('seed first second', $r['data']); } public function testPriority() { $d = new CvDispatcher(); - $d->addListener('foo', function ($event) { + $d->addListener('myapp.foo', function ($event) { static::addLog('foo.3.1'); }, 3); - $d->addListener('foo', function ($event) { + $d->addListener('myapp.foo', function ($event) { static::addLog('foo.-200.1'); }, -200); - $d->addListener('foo', function ($event) { + $d->addListener('myapp.foo', function ($event) { static::addLog('foo.1.1'); }, 1); - $d->addListener('foo', function ($event) { + $d->addListener('myapp.foo', function ($event) { static::addLog('foo.2.1'); }, 2); - $d->addListener('foo', function ($event) { + $d->addListener('myapp.foo', function ($event) { static::addLog('foo.1.2'); }, 1); - $d->addListener('foo', function ($event) { + $d->addListener('myapp.foo', function ($event) { static::addLog('foo.1.3'); }, 1); - $d->dispatch(new CvEvent([]), 'foo'); + $d->addListener('*.foo', function ($event) { + static::addLog('wildFoo.1.1'); + }, 1); + $d->addListener('*.foo', function ($event) { + static::addLog('wildFoo.2.1'); + }, 2); + + $d->dispatch(new CvEvent([]), 'myapp.foo'); $this->assertEquals([ 'foo.-200.1', 'foo.1.1', 'foo.1.2', 'foo.1.3', + 'wildFoo.1.1', 'foo.2.1', + 'wildFoo.2.1', 'foo.3.1', ], static::$log); } From 8dbaab553fff6bec17e60e5f6c4b356a471dcda3 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 5 Mar 2024 01:11:07 -0500 Subject: [PATCH 06/18] Add AliasFilter for translating `@foo` to `--site-alias=foo` --- lib/src/BaseApplication.php | 37 +++++++++++++++++++++++++++++------- lib/src/Util/AliasFilter.php | 32 +++++++++++++++++++++++++++++++ lib/src/Util/CvArgvInput.php | 24 +++++++++++++++++++++++ 3 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 lib/src/Util/AliasFilter.php create mode 100644 lib/src/Util/CvArgvInput.php diff --git a/lib/src/BaseApplication.php b/lib/src/BaseApplication.php index 43add52b..92ffd95e 100644 --- a/lib/src/BaseApplication.php +++ b/lib/src/BaseApplication.php @@ -1,8 +1,9 @@ init(); - $application = Cv::filter("${name}.app.boot", [ - 'app' => new $class($name, static::version() ?? 'UNKNOWN'), - ])['app']; - - $input = new ArgvInput($argv); - $output = new ConsoleOutput(); + $appEvent = ['app' => new $class($name, static::version() ?? 'UNKNOWN')]; + $appEvent = Cv::filter('cv.app.boot', $appEvent); + $application = $appEvent['app']; $application->setAutoExit(FALSE); ErrorHandler::pushHandler(); + try { + $argv = AliasFilter::filter($argv); + $input = new CvArgvInput($argv); + $output = new ConsoleOutput(); + $result = $application->run($input, $output); } finally { @@ -78,7 +81,27 @@ public function doRun(InputInterface $input, OutputInterface $output) { throw new \RuntimeException("Failed to use directory specified, $workingDir as working directory."); } } + Cv::filter($this->getName() . '.app.run', []); + + if ($input->hasParameterOption('--site-alias')) { + $aliasEvent = Cv::filter($this->getName() . ".app.site-alias", [ + 'alias' => $input->getParameterOption('--site-alias'), + 'app' => $this, + 'input' => $input, + 'output' => $output, + 'argv' => $input->getOriginalArgv(), + 'transport' => NULL, + 'exec' => function() use (&$aliasEvent) { + return parent::doRun($aliasEvent['input'], $aliasEvent['output']); + }, + ]); + if (empty($aliasEvent['transport'])) { + throw new \RuntimeException("Unknown site alias: " . $aliasEvent['alias']); + } + return call_user_func($aliasEvent['transport'], $aliasEvent); + } + return parent::doRun($input, $output); } diff --git a/lib/src/Util/AliasFilter.php b/lib/src/Util/AliasFilter.php new file mode 100644 index 00000000..d6322a63 --- /dev/null +++ b/lib/src/Util/AliasFilter.php @@ -0,0 +1,32 @@ + 0) { + $value = array_shift($todo); + if ($value[0] === '@') { + $result[] = '--site-alias=' . substr($value, 1); + $result = array_merge($result, $todo); + $todo = []; + } + else { + $result[] = $value; + } + } + return $result; + } + +} diff --git a/lib/src/Util/CvArgvInput.php b/lib/src/Util/CvArgvInput.php new file mode 100644 index 00000000..27b00ba4 --- /dev/null +++ b/lib/src/Util/CvArgvInput.php @@ -0,0 +1,24 @@ +originalArgv = $argv; + parent::__construct($argv, $definition); + } + + public function getOriginalArgv(): array { + return $this->originalArgv; + } + +} From ebcbbccbd6892070f5ddbc873bba5b9fe2d4b747 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 5 Mar 2024 14:38:26 -0800 Subject: [PATCH 07/18] doc/plugins.md - More info --- doc/plugins.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/doc/plugins.md b/doc/plugins.md index 5bd44fe3..17c273df 100644 --- a/doc/plugins.md +++ b/doc/plugins.md @@ -46,7 +46,19 @@ This sequencing meaning that some early events (e.g. `cv.app.boot` or `cv.confi ## Events -* `cv.app.boot` (*global-only*): Fires immediately when the application starts -* `cv.app.run` (*global-only*): Fires when the application begins executing a command -* `cv.app.commands` (*global-only*): Fires when the application builds a list of available commands +* `cv.app.boot`: Fires immediately when the application starts + * __Argument__: `$e['app']`: Reference to the `Application` object +* `cv.app.commands`: Fires when the application builds a list of available commands * __Argument__: `$e['commands`]`: alterable list of commands +* `cv.app.run`: Fires when the application begins executing a command + * __Argument__: `$e['app']`: Reference to the `Application` object +* `cv.app.site-alias`: Fires if the command is called with an alias (eg `cv @mysite ext:list`) + * __Argument__: `$e['alias']`: The name of the alias + * __Argument__: `$e['app']`: Reference to the `Application` object + * __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['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.) \ No newline at end of file From 282345fe6cbf08868e0439d22069f951e8879384 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 5 Mar 2024 15:48:12 -0800 Subject: [PATCH 08/18] BaseApplication - Switch back to earlier teardown logic for error handler --- lib/src/BaseApplication.php | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/src/BaseApplication.php b/lib/src/BaseApplication.php index 92ffd95e..2e1cd783 100644 --- a/lib/src/BaseApplication.php +++ b/lib/src/BaseApplication.php @@ -16,6 +16,8 @@ class BaseApplication extends \Symfony\Component\Console\Application { * Primary entry point for execution of the standalone command. */ public static function main(string $name, ?string $binDir, array $argv) { + ErrorHandler::pushHandler(); + $class = static::class; Cv::plugins()->init(); @@ -24,18 +26,16 @@ public static function main(string $name, ?string $binDir, array $argv) { $application = $appEvent['app']; $application->setAutoExit(FALSE); - ErrorHandler::pushHandler(); - try { - $argv = AliasFilter::filter($argv); - $input = new CvArgvInput($argv); - $output = new ConsoleOutput(); + $argv = AliasFilter::filter($argv); + $input = new CvArgvInput($argv); + $output = new ConsoleOutput(); + $result = $application->run($input, $output); + + ## NOTE: We do *not* use try/finally here. Doing so seems to counterintuitively + ## muck with the exit code in some cases (eg `testPhpEval_ExitCodeError()`). + ErrorHandler::popHandler(); - $result = $application->run($input, $output); - } - finally { - ErrorHandler::popHandler(); - } exit($result); } From 9387268f165bc546735fba04197150bdd6e81115 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Tue, 5 Mar 2024 16:33:24 -0800 Subject: [PATCH 09/18] AliasFilter - Only use aliases at the start. Avoid confusion with pre-existing "@" arguments. --- lib/src/Util/AliasFilter.php | 36 +++++++++++--------- tests/Util/AliasFilterTest.php | 62 ++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 16 deletions(-) create mode 100644 tests/Util/AliasFilterTest.php diff --git a/lib/src/Util/AliasFilter.php b/lib/src/Util/AliasFilter.php index d6322a63..2966e335 100644 --- a/lib/src/Util/AliasFilter.php +++ b/lib/src/Util/AliasFilter.php @@ -2,31 +2,35 @@ namespace Civi\Cv\Util; +use Symfony\Component\Console\Input\ArgvInput; + class AliasFilter { /** * Find an option like `cv @mysite ext:list`. Convert the `@mysite` * notation to `--site-alias=mysite`. * - * @param array $input + * @param array $argv * @return array */ - public static function filter(array $input): array { - $todo = $input; - $result = []; - $result[] = array_shift($todo); - while (count($todo) > 0) { - $value = array_shift($todo); - if ($value[0] === '@') { - $result[] = '--site-alias=' . substr($value, 1); - $result = array_merge($result, $todo); - $todo = []; - } - else { - $result[] = $value; - } + public static function filter(array $argv): array { + if (!preg_grep('/^@/', $argv)) { + return $argv; + } + + $input = new ArgvInput($argv); + $firstArg = $input->getFirstArgument(); + if ($firstArg[0] === '@') { + return static::replace($argv, $firstArg, '--site-alias=' . substr($firstArg, 1)); } - return $result; + + return $argv; + } + + private static function replace(array $original, $old, $new) { + $pos = array_search($old, $original, TRUE); + $original[$pos] = $new; + return $original; } } diff --git a/tests/Util/AliasFilterTest.php b/tests/Util/AliasFilterTest.php new file mode 100644 index 00000000..920ecd59 --- /dev/null +++ b/tests/Util/AliasFilterTest.php @@ -0,0 +1,62 @@ +addOption('bare', 'b', InputOption::VALUE_NONE, 'Perform a basic download in a non-bootstrapped environment. Implies --level=none, --no-install, and no --refresh. You must specify the download URL.'); + // $command->addOption('to', NULL, InputOption::VALUE_OPTIONAL, 'Download to a specific directory (absolute path).'); + // $command->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.'); + // $app->add($command); + + $actualOutput = AliasFilter::filter($inputArray); + $this->assertEquals($expectOutput, $actualOutput); + } + +} From 6a2973b598834d10176e551e4e9892ff59c536fb Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Sat, 8 Jun 2024 16:09:06 +0200 Subject: [PATCH 10/18] CvPlugins - Fix error message --- lib/src/CvPlugins.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/CvPlugins.php b/lib/src/CvPlugins.php index f1af8a94..cbb69757 100644 --- a/lib/src/CvPlugins.php +++ b/lib/src/CvPlugins.php @@ -43,7 +43,7 @@ public function init() { $this->plugins[$pluginName] = $file; } else { - fprintf(STDERR, "WARNING: Plugin %s has multiple definitions (%s, %s)\n", $file, $this->plugins[$pluginName]); + fprintf(STDERR, "WARNING: Plugin %s has multiple definitions (%s, %s)\n", $pluginName, $file, $this->plugins[$pluginName]); } } } From d7719ecd19ffd99d230ff98bdb4eed46fde28acd Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Sat, 8 Jun 2024 16:23:51 +0200 Subject: [PATCH 11/18] Add HelloPluginTest --- tests/Plugin/HelloPluginTest.php | 24 ++++++++++++++++++++++++ tests/Plugin/HelloPluginTest/hello.php | 23 +++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 tests/Plugin/HelloPluginTest.php create mode 100644 tests/Plugin/HelloPluginTest/hello.php diff --git a/tests/Plugin/HelloPluginTest.php b/tests/Plugin/HelloPluginTest.php new file mode 100644 index 00000000..6b933f93 --- /dev/null +++ b/tests/Plugin/HelloPluginTest.php @@ -0,0 +1,24 @@ +setEnv(['CV_PLUGIN_PATH' => preg_replace(';\.php$;', '', __FILE__)]); + return $process; + } + + public function testRun() { + $output = $this->cvOk('hello'); + $this->assertMatchesRegularExpression('/^Hello there/', $output); + } + +} diff --git a/tests/Plugin/HelloPluginTest/hello.php b/tests/Plugin/HelloPluginTest/hello.php new file mode 100644 index 00000000..9d808ec7 --- /dev/null +++ b/tests/Plugin/HelloPluginTest/hello.php @@ -0,0 +1,23 @@ + 1) { + die("Expect CV_PLUGIN API v1"); +} + +Cv::dispatcher()->addListener('cv.app.commands', function ($e) { + $e['commands'][] = new class extends \Symfony\Component\Console\Command\Command { + + protected function configure() { + $this->setName('hello')->setDescription('Say a greeting'); + } + + protected function execute(InputInterface $input, OutputInterface $output) { + $output->writeln('Hello there!'); + } + + }; +}); From 790c1b35df3cb864c398eb308db62068ea4abf38 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Sat, 8 Jun 2024 17:41:23 +0200 Subject: [PATCH 12/18] Plugins - All environments should support common prefix --- bin/cv | 1 + doc/plugins.md | 19 +++++++++++++--- scoper.inc.php | 3 +++ src/ClassAliases.php | 31 ++++++++++++++++++++++++++ tests/Plugin/HelloPluginTest/hello.php | 7 +++--- 5 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 src/ClassAliases.php diff --git a/bin/cv b/bin/cv index 1fe7d44e..09b77236 100755 --- a/bin/cv +++ b/bin/cv @@ -26,4 +26,5 @@ foreach ($autoloaders as $autoloader) { if (!$found) { die("Failed to find autoloader"); } +\Civi\Cv\ClassAliases::register(); \Civi\Cv\Application::main('cv', __DIR__, $argv); diff --git a/doc/plugins.md b/doc/plugins.md index 17c273df..43860bce 100644 --- a/doc/plugins.md +++ b/doc/plugins.md @@ -7,13 +7,14 @@ Cv plugins are PHP files which register event listeners. ```php // FILE: /etc/cv/plugin/hello-command.php use Civi\Cv\Cv; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; +use CvDeps\Symfony\Component\Console\Input\InputInterface; +use CvDeps\Symfony\Component\Console\Output\OutputInterface; +use CvDeps\Symfony\Component\Console\Command\Command; if (empty($CV_PLUGIN['protocol']) || $CV_PLUGIN['protocol'] > 1) die("Expect CV_PLUGIN API v1"); Cv::dispatcher()->addListener('cv.app.commands', function($e) { - $e['commands'][] = new class extends \Symfony\Component\Console\Command\Command { + $e['commands'][] = new class extends Command { protected function configure() { $this->setName('hello')->setDescription('Say a greeting'); } @@ -44,6 +45,18 @@ After loading the global plugins, `cv` reads the the `cv.yml` and then loads any This sequencing meaning that some early events (e.g. `cv.app.boot` or `cv.config.find`) are only available to *global plugins*. --> +## Namespacing + +The plugin itself may live in a global namespace or its own namespace. + +When a plugin refers to another class, it will be affected by `cv`'s namespace-prefixing: + +* Classes provided by CiviCRM and the user-framework (eg `Civi\*`, `CRM_*`, `Drupal\*`) are referenced by their original names. +* Classes provided by `cv`'s internal dependencies (eg `Symfony\Component\Console\*`) should be accessed with the prefix `CvDeps\*` (eg `CvDeps\Symfony\Component\Console\*`). + +(Technically, `cv`'s internal dependencies may have different concrete names depending on how `cv` is installed. The prefix `CvDeps\` is a logical alias that will work in +more environments.) + ## Events * `cv.app.boot`: Fires immediately when the application starts diff --git a/scoper.inc.php b/scoper.inc.php index 419870e3..e7a94846 100644 --- a/scoper.inc.php +++ b/scoper.inc.php @@ -3,6 +3,9 @@ return [ 'prefix' => 'Cvphar', 'exclude-namespaces' => [ + // Provided by cv + 'CvDeps', + // Provided by civicrm 'Civi', 'Guzzle', diff --git a/src/ClassAliases.php b/src/ClassAliases.php new file mode 100644 index 00000000..81d12fb6 --- /dev/null +++ b/src/ClassAliases.php @@ -0,0 +1,31 @@ + 1) { die("Expect CV_PLUGIN API v1"); } Cv::dispatcher()->addListener('cv.app.commands', function ($e) { - $e['commands'][] = new class extends \Symfony\Component\Console\Command\Command { + $e['commands'][] = new class extends Command { protected function configure() { $this->setName('hello')->setDescription('Say a greeting'); From c0ae091e2f740d80266d441c088a99f0bb4aa104 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Sun, 9 Jun 2024 15:35:13 -0700 Subject: [PATCH 13/18] Add AliasPluginTest --- tests/Plugin/AliasPluginTest.php | 30 ++++++++++++ tests/Plugin/AliasPluginTest/dummy-alias.php | 50 ++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 tests/Plugin/AliasPluginTest.php create mode 100644 tests/Plugin/AliasPluginTest/dummy-alias.php diff --git a/tests/Plugin/AliasPluginTest.php b/tests/Plugin/AliasPluginTest.php new file mode 100644 index 00000000..6c2b8d63 --- /dev/null +++ b/tests/Plugin/AliasPluginTest.php @@ -0,0 +1,30 @@ +setEnv(['CV_PLUGIN_PATH' => preg_replace(';\.php$;', '', __FILE__)]); + return $process; + } + + public function testDummyAlias() { + $output = $this->cvOk('@dummy ext:list -Li'); + $this->assertMatchesRegularExpression(";^DUMMY: '.*/(cv|cv.phar)' --site-alias=dummy 'ext:list' -Li;", $output); + } + + public function testUnknownAlias() { + $output = $this->cvFail('@eldorado ext:list -Li'); + $this->assertMatchesRegularExpression('/Unknown site alias: eldorado/', $output); + } + +} diff --git a/tests/Plugin/AliasPluginTest/dummy-alias.php b/tests/Plugin/AliasPluginTest/dummy-alias.php new file mode 100644 index 00000000..334b2c0a --- /dev/null +++ b/tests/Plugin/AliasPluginTest/dummy-alias.php @@ -0,0 +1,50 @@ + 1) { + die("Expect CV_PLUGIN API v1"); +} + +Cv::dispatcher()->addListener('*.app.site-alias', function(CvEvent $event) { + if ($event['alias'] === 'dummy') { + + foreach (['app', 'output', 'input'] as $key) { + if (empty($event[$key])) { + throw new \RuntimeException("Event *.app.site-alias is missing value for \"$key\""); + } + } + + /** + * @var \Civi\Cv\Util\CvArgvInput $input + */ + $input = $event['input']; + + /** + * @var \CvDeps\Symfony\Component\Console\Output\OutputInterface $output + */ + $output = $event['output']; + + $args = array_map(__NAMESPACE__ . '\\escapeString', $input->getOriginalArgv()); + $fullCommand = implode(' ', $args); + + $event['transport'] = function() use ($input, $output, $fullCommand) { + $output->writeln("DUMMY: $fullCommand", OutputInterface::OUTPUT_RAW); + }; + } +}); + +function escapeString(string $expr): string { + return preg_match('{^[\w=-]+$}', $expr) ? $expr : escapeshellarg($expr); +} From e30ba9a67c6997e075c9ac729aae05d7daba6035 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Sun, 9 Jun 2024 16:14:37 -0700 Subject: [PATCH 14/18] Define helpers Cv::io(), Cv::input(), Cv::output() using IOStack Similar to Civix --- lib/src/BaseApplication.php | 14 +++++++ lib/src/Cv.php | 35 +++++++++++++++++ lib/src/Util/CvArgvInput.php | 3 +- lib/src/Util/IOStack.php | 75 ++++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 lib/src/Util/IOStack.php diff --git a/lib/src/BaseApplication.php b/lib/src/BaseApplication.php index 2e1cd783..da2828c3 100644 --- a/lib/src/BaseApplication.php +++ b/lib/src/BaseApplication.php @@ -4,6 +4,7 @@ use Civi\Cv\Util\AliasFilter; use Civi\Cv\Util\CvArgvInput; use LesserEvil\ShellVerbosityIsEvil; +use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\ConsoleOutput; @@ -59,6 +60,19 @@ protected function getDefaultInputDefinition() { return $definition; } + public function run(?InputInterface $input = NULL, ?OutputInterface $output = NULL) { + $input = $input ?: new CvArgvInput(); + $output = $output ?: new ConsoleOutput(); + + try { + Cv::ioStack()->push($input, $output); + return parent::run($input, $output); + } + finally { + Cv::ioStack()->pop(); + } + } + /** * {@inheritdoc} */ diff --git a/lib/src/Cv.php b/lib/src/Cv.php index 2f6c172f..bb5e4915 100644 --- a/lib/src/Cv.php +++ b/lib/src/Cv.php @@ -2,6 +2,8 @@ namespace Civi\Cv; +use Civi\Cv\Util\IOStack; + class Cv { protected static $instances = []; @@ -65,6 +67,39 @@ public static function filter($eventName, array $data) { return $event->getArguments(); } + /** + * Get a list of input/output objects for pending commands. + * + * @return \Civi\Cv\Util\IOStack + */ + public static function ioStack(): IOStack { + if (!isset(static::$instances[__FUNCTION__])) { + static::$instances[__FUNCTION__] = new IOStack(); + } + return static::$instances[__FUNCTION__]; + } + + /** + * @return \CvDeps\Symfony\Component\Console\Input\InputInterface|\Symfony\Component\Console\Input\InputInterface + */ + public static function input() { + return static::ioStack()->current('input'); + } + + /** + * @return \CvDeps\Symfony\Component\Console\Output\OutputInterface|\Symfony\Component\Console\Output\OutputInterface + */ + public static function output() { + return static::ioStack()->current('output'); + } + + /** + * @return \CvDeps\Symfony\Component\Console\Style\StyleInterface|\Symfony\Component\Console\Style\StyleInterface + */ + public static function io() { + return static::ioStack()->current('io'); + } + /** * Get the plugin manager. * diff --git a/lib/src/Util/CvArgvInput.php b/lib/src/Util/CvArgvInput.php index 27b00ba4..72f122d5 100644 --- a/lib/src/Util/CvArgvInput.php +++ b/lib/src/Util/CvArgvInput.php @@ -12,7 +12,8 @@ class CvArgvInput extends ArgvInput { protected $originalArgv; - public function __construct(array $argv, 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/lib/src/Util/IOStack.php b/lib/src/Util/IOStack.php new file mode 100644 index 00000000..08f85e56 --- /dev/null +++ b/lib/src/Util/IOStack.php @@ -0,0 +1,75 @@ +stack, [ + 'id' => static::$id, + 'input' => $input, + 'output' => $output, + 'io' => new SymfonyStyle($input, $output), + ]); + return static::$id; + } + + public function pop(): array { + return array_shift($this->stack); + } + + /** + * Get a current property of the current (top) stack-frame. + * + * @param string $property + * One of: 'input', 'output', 'io', 'id' + * @return mixed + */ + public function current(string $property) { + return $this->stack[0][$property]; + } + + /** + * Lookup a property from a particular stack-frame. + * + * @param scalar $id + * Internal identifier for the stack-frame. + * @param string $property + * One of: 'input', 'output', 'io', 'id' + * @return mixed|null + */ + public function get($id, string $property) { + foreach ($this->stack as $item) { + if ($item['id'] === $id) { + return $item[$property]; + } + } + return NULL; + } + + public function reset() { + $this->stack = []; + } + +} From 6053f66ab4a3737b05a00f6ed560821e780d2b2b Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Sun, 9 Jun 2024 17:08:16 -0700 Subject: [PATCH 15/18] IOStack - Provide some basic input/output before loading any plugins --- lib/src/BaseApplication.php | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/lib/src/BaseApplication.php b/lib/src/BaseApplication.php index da2828c3..e3d28a76 100644 --- a/lib/src/BaseApplication.php +++ b/lib/src/BaseApplication.php @@ -4,7 +4,6 @@ use Civi\Cv\Util\AliasFilter; use Civi\Cv\Util\CvArgvInput; use LesserEvil\ShellVerbosityIsEvil; -use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\ConsoleOutput; @@ -19,19 +18,26 @@ class BaseApplication extends \Symfony\Component\Console\Application { public static function main(string $name, ?string $binDir, array $argv) { ErrorHandler::pushHandler(); - $class = static::class; + $preBootInput = new CvArgvInput([$argv[0]]); + $preBootOutput = new ConsoleOutput(); + Cv::ioStack()->push($preBootInput, $preBootOutput); - Cv::plugins()->init(); - $appEvent = ['app' => new $class($name, static::version() ?? 'UNKNOWN')]; - $appEvent = Cv::filter('cv.app.boot', $appEvent); - $application = $appEvent['app']; + try { + $class = static::class; + + Cv::plugins()->init(); + $appEvent = ['app' => new $class($name, static::version() ?? 'UNKNOWN')]; + $appEvent = Cv::filter('cv.app.boot', $appEvent); + $application = $appEvent['app']; - $application->setAutoExit(FALSE); + $application->setAutoExit(FALSE); - $argv = AliasFilter::filter($argv); - $input = new CvArgvInput($argv); - $output = new ConsoleOutput(); - $result = $application->run($input, $output); + $argv = AliasFilter::filter($argv); + $result = $application->run(new CvArgvInput($argv), Cv::ioStack()->current('output')); + } + finally { + Cv::ioStack()->pop(); + } ## NOTE: We do *not* use try/finally here. Doing so seems to counterintuitively ## muck with the exit code in some cases (eg `testPhpEval_ExitCodeError()`). From dcb4479df71738dbd8c15f70ae18bf2266e4896b Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Sun, 9 Jun 2024 17:08:43 -0700 Subject: [PATCH 16/18] Incorporate Cv::io (etc) into dev-docs and HelloPluginTest --- doc/plugins.md | 15 ++++++++++++++- tests/Plugin/HelloPluginTest.php | 7 ++++++- tests/Plugin/HelloPluginTest/hello.php | 13 +++++++++++-- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/doc/plugins.md b/doc/plugins.md index 43860bce..ed7731e9 100644 --- a/doc/plugins.md +++ b/doc/plugins.md @@ -74,4 +74,17 @@ more environments.) * __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. -(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.) \ No newline at end of file +(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.) + +## Global helpers + +The `\Civi\Cv\Cv` facade provides some helpers for implementing functionality: + +* 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.*) diff --git a/tests/Plugin/HelloPluginTest.php b/tests/Plugin/HelloPluginTest.php index 6b933f93..0fd766ce 100644 --- a/tests/Plugin/HelloPluginTest.php +++ b/tests/Plugin/HelloPluginTest.php @@ -18,7 +18,12 @@ protected function cv($command) { public function testRun() { $output = $this->cvOk('hello'); - $this->assertMatchesRegularExpression('/^Hello there/', $output); + $this->assertMatchesRegularExpression('/Hello world via parameter.*Hello world via StyleInterface/s', $output); + } + + public function testRunWithName() { + $output = $this->cvOk('hello Bob'); + $this->assertMatchesRegularExpression('/Hello Bob via parameter.*Hello Bob via StyleInterface/s', $output); } } diff --git a/tests/Plugin/HelloPluginTest/hello.php b/tests/Plugin/HelloPluginTest/hello.php index e524d630..f21c4256 100644 --- a/tests/Plugin/HelloPluginTest/hello.php +++ b/tests/Plugin/HelloPluginTest/hello.php @@ -9,15 +9,24 @@ die("Expect CV_PLUGIN API v1"); } +Cv::dispatcher()->addListener('*.app.boot', function ($e) { + Cv::io()->writeln("Hello during initial bootstrap!"); +}); + Cv::dispatcher()->addListener('cv.app.commands', function ($e) { $e['commands'][] = new class extends Command { protected function configure() { - $this->setName('hello')->setDescription('Say a greeting'); + $this->setName('hello')->setDescription('Say a greeting')->addArgument('name'); } protected function execute(InputInterface $input, OutputInterface $output) { - $output->writeln('Hello there!'); + if ($input->getArgument('name') !== Cv::input()->getArgument('name')) { + throw new \RuntimeException("Argument \"name\" is inconsistent!"); + } + $name = $input->getArgument('name') ?: 'world'; + $output->writeln("Hello $name via parameter!"); + Cv::io()->writeln("Hello $name via StyleInterface!"); } }; From ab53147c3c40e8304838edfe5650534e11ee059a Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 10 Jun 2024 11:09:13 -0700 Subject: [PATCH 17/18] Expose more info to plugin init. Cleanup Application<=>BaseApplication contracts. --- doc/plugins.md | 22 +++++++++++++++------- lib/src/BaseApplication.php | 23 +++++++++++++---------- lib/src/CvPlugins.php | 8 ++++++-- src/Application.php | 13 +++++++++++-- tests/Plugin/HelloPluginTest/hello.php | 15 +++++++++++++++ 5 files changed, 60 insertions(+), 21 deletions(-) diff --git a/doc/plugins.md b/doc/plugins.md index ed7731e9..cc81b05e 100644 --- a/doc/plugins.md +++ b/doc/plugins.md @@ -49,13 +49,12 @@ This sequencing meaning that some early events (e.g. `cv.app.boot` or `cv.confi The plugin itself may live in a global namespace or its own namespace. -When a plugin refers to another class, it will be affected by `cv`'s namespace-prefixing: +Plugins execute within `cv`'s process, so they are affected by `cv`'s namespace-prefixing rules: -* Classes provided by CiviCRM and the user-framework (eg `Civi\*`, `CRM_*`, `Drupal\*`) are referenced by their original names. -* Classes provided by `cv`'s internal dependencies (eg `Symfony\Component\Console\*`) should be accessed with the prefix `CvDeps\*` (eg `CvDeps\Symfony\Component\Console\*`). - -(Technically, `cv`'s internal dependencies may have different concrete names depending on how `cv` is installed. The prefix `CvDeps\` is a logical alias that will work in -more environments.) +* External dependencies (eg CiviCRM, Drupal, WordPress) are not provided by `cv`. They do not have prefixing. + Access these with their canonical names (eg `Civi\*`, `CRM_*`, `Drupal\*`). +* 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\*`). ## Events @@ -76,7 +75,7 @@ more environments.) (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.) -## Global helpers +## `Cv` helpers The `\Civi\Cv\Cv` facade provides some helpers for implementing functionality: @@ -88,3 +87,12 @@ The `\Civi\Cv\Cv` facade provides some helpers for implementing functionality: * __`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.*) + +## `$CV_PLUGIN` data + +When loading a plugin, the variable `$CV_PLUGIN` is prepopulated with information about the plugin and its environment. + +* __Property__: `$CV_PLUGIN['appName']`: Logical name of the CLI application +* __Property__: `$CV_PLUGIN['appVersion']`: Version of the main application +* __Property__: `$CV_PLUGIN['name']`: Logical name of the plugin +* __Property__: `$CV_PLUGIN['file']`: Full path to the plugin-file diff --git a/lib/src/BaseApplication.php b/lib/src/BaseApplication.php index e3d28a76..9c4c668f 100644 --- a/lib/src/BaseApplication.php +++ b/lib/src/BaseApplication.php @@ -18,20 +18,12 @@ class BaseApplication extends \Symfony\Component\Console\Application { public static function main(string $name, ?string $binDir, array $argv) { ErrorHandler::pushHandler(); - $preBootInput = new CvArgvInput([$argv[0]]); + $preBootInput = new CvArgvInput($argv); $preBootOutput = new ConsoleOutput(); Cv::ioStack()->push($preBootInput, $preBootOutput); try { - $class = static::class; - - Cv::plugins()->init(); - $appEvent = ['app' => new $class($name, static::version() ?? 'UNKNOWN')]; - $appEvent = Cv::filter('cv.app.boot', $appEvent); - $application = $appEvent['app']; - - $application->setAutoExit(FALSE); - + $application = new static($name); $argv = AliasFilter::filter($argv); $result = $application->run(new CvArgvInput($argv), Cv::ioStack()->current('output')); } @@ -49,6 +41,10 @@ public static function main(string $name, ?string $binDir, array $argv) { public function __construct($name = 'UNKNOWN', $version = 'UNKNOWN') { parent::__construct($name, $version); $this->setCatchExceptions(TRUE); + $this->setAutoExit(FALSE); + + Cv::plugins()->init(['appName' => $this->getName(), 'appVersion' => $this->getVersion()]); + Cv::filter($this->getName() . '.app.boot', ['app' => $this]); $commands = Cv::filter($this->getName() . '.app.commands', [ 'commands' => $this->createCommands(), @@ -56,6 +52,13 @@ public function __construct($name = 'UNKNOWN', $version = 'UNKNOWN') { $this->addCommands($commands); } + /** + * @return \Symfony\Component\Console\Command\Command[] + */ + public function createCommands($context = 'default') { + return []; + } + /** * {@inheritdoc} */ diff --git a/lib/src/CvPlugins.php b/lib/src/CvPlugins.php index cbb69757..c4583fd0 100644 --- a/lib/src/CvPlugins.php +++ b/lib/src/CvPlugins.php @@ -16,8 +16,12 @@ class CvPlugins { * This will scan any folders listed in CV_PLUGIN_PATH. If CV_PLUGIN_PATH * is undefined, then the default will be * `$HOME/.cv/plugin:/etc/cv/plugin:/usr/local/share/cv/plugin:/usr/share/cv/plugin`. + * + * @param array $pluginEnv + * Description the current application environment. + * Ex: ['appName' => 'cv', 'appVersion' => '0.3.50'] */ - public function init() { + public function init(array $pluginEnv) { if (getenv('CV_PLUGIN_PATH')) { $this->paths = explode(PATH_SEPARATOR, getenv('CV_PLUGIN_PATH')); } @@ -52,7 +56,7 @@ public function init() { ksort($this->plugins); foreach ($this->plugins as $pluginName => $pluginFile) { // FIXME: Refactor so that you can add more plugins post-boot `load("/some/glob*.php")` - $this->load([ + $this->load($pluginEnv + [ 'protocol' => 1, 'name' => $pluginName, 'file' => $pluginFile, diff --git a/src/Application.php b/src/Application.php index 363a3116..f47a5bb5 100644 --- a/src/Application.php +++ b/src/Application.php @@ -3,11 +3,20 @@ class Application extends BaseApplication { + const DEV_VERSION = '0.3.x'; + protected $deprecatedAliases = [ 'debug:container' => 'service', 'debug:event-dispatcher' => 'event', ]; + public function __construct($name = 'UNKNOWN', $version = 'UNKNOWN') { + if ($version === 'UNKNOWN') { + $version = static::version() ?? 'UNKNOWN'; + } + parent::__construct($name, $version); + } + /** * Determine the version number. * @@ -19,7 +28,7 @@ public static function version(): ?string { $marker = '@' . 'package' . '_' . 'version' . '@'; $v = '@package_version@'; if ($v !== $marker) { - return $v; + return ltrim($v, 'v'); } if (is_callable('\Composer\InstalledVersions::getVersion')) { $v = \Composer\InstalledVersions::getVersion('civicrm/cv'); @@ -27,7 +36,7 @@ public static function version(): ?string { return $v; } } - return NULL; + return static::DEV_VERSION; } /** diff --git a/tests/Plugin/HelloPluginTest/hello.php b/tests/Plugin/HelloPluginTest/hello.php index f21c4256..a62a74b6 100644 --- a/tests/Plugin/HelloPluginTest/hello.php +++ b/tests/Plugin/HelloPluginTest/hello.php @@ -9,6 +9,21 @@ 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("Hello during initial bootstrap!"); }); From 393f78f4c1f2fdebcfd2f61a4701bba5883403da Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 9 Sep 2024 23:27:38 -0700 Subject: [PATCH 18/18] Updates for Symfony 5 compat --- doc/plugins.md | 3 ++- tests/Plugin/HelloPluginTest/hello.php | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/doc/plugins.md b/doc/plugins.md index cc81b05e..2ef6fd7b 100644 --- a/doc/plugins.md +++ b/doc/plugins.md @@ -18,8 +18,9 @@ Cv::dispatcher()->addListener('cv.app.commands', function($e) { protected function configure() { $this->setName('hello')->setDescription('Say a greeting'); } - protected function execute(InputInterface $input, OutputInterface $output) { + protected function execute(InputInterface $input, OutputInterface $output): int { $output->writeln('Hello there!'); + return 0; } }; }); diff --git a/tests/Plugin/HelloPluginTest/hello.php b/tests/Plugin/HelloPluginTest/hello.php index a62a74b6..e454015e 100644 --- a/tests/Plugin/HelloPluginTest/hello.php +++ b/tests/Plugin/HelloPluginTest/hello.php @@ -35,13 +35,14 @@ protected function configure() { $this->setName('hello')->setDescription('Say a greeting')->addArgument('name'); } - protected function execute(InputInterface $input, OutputInterface $output) { + protected function execute(InputInterface $input, OutputInterface $output): int { if ($input->getArgument('name') !== Cv::input()->getArgument('name')) { throw new \RuntimeException("Argument \"name\" is inconsistent!"); } $name = $input->getArgument('name') ?: 'world'; $output->writeln("Hello $name via parameter!"); Cv::io()->writeln("Hello $name via StyleInterface!"); + return 0; } };