diff --git a/bin/cv b/bin/cv index 59c5eb8b..09b77236 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; @@ -26,4 +26,5 @@ foreach ($autoloaders as $autoloader) { if (!$found) { die("Failed to find autoloader"); } -\Civi\Cv\Application::main(__DIR__); +\Civi\Cv\ClassAliases::register(); +\Civi\Cv\Application::main('cv', __DIR__, $argv); diff --git a/doc/plugins.md b/doc/plugins.md new file mode 100644 index 00000000..2ef6fd7b --- /dev/null +++ b/doc/plugins.md @@ -0,0 +1,99 @@ +# 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 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 Command { + protected function configure() { + $this->setName('hello')->setDescription('Say a greeting'); + } + protected function execute(InputInterface $input, OutputInterface $output): int { + $output->writeln('Hello there!'); + return 0; + } + }; +}); +``` + +## 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 +``` + + + +## Namespacing + +The plugin itself may live in a global namespace or its own namespace. + +Plugins execute within `cv`'s process, so they are affected by `cv`'s namespace-prefixing rules: + +* 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 + +* `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.) + +## `Cv` 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.*) + +## `$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 new file mode 100644 index 00000000..9c4c668f --- /dev/null +++ b/lib/src/BaseApplication.php @@ -0,0 +1,137 @@ +push($preBootInput, $preBootOutput); + + try { + $application = new static($name); + $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()`). + ErrorHandler::popHandler(); + + exit($result); + } + + 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(), + ])['commands']; + $this->addCommands($commands); + } + + /** + * @return \Symfony\Component\Console\Command\Command[] + */ + public function createCommands($context = 'default') { + return []; + } + + /** + * {@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; + } + + 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} + */ + 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', []); + + 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); + } + + protected function configureIO(InputInterface $input, OutputInterface $output) { + ShellVerbosityIsEvil::doWithoutEvil(function() use ($input, $output) { + parent::configureIO($input, $output); + }); + } + +} diff --git a/lib/src/Cv.php b/lib/src/Cv.php new file mode 100644 index 00000000..bb5e4915 --- /dev/null +++ b/lib/src/Cv.php @@ -0,0 +1,115 @@ +dispatch($event, $eventName); + 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. + * + * @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..73161f99 --- /dev/null +++ b/lib/src/CvDispatcher.php @@ -0,0 +1,70 @@ +listeners[$name])) { + $activeListeners = array_merge($activeListeners, $this->listeners[$name]); + } + } + + 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][$id] = ['callback' => $callback, 'priority' => $priority, 'natPriority' => $natPriority]; + } + + public function removeListener(string $eventName, $callback) { + $id = $this->getCallbackId($callback); + unset($this->listeners[$eventName][$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..c4583fd0 --- /dev/null +++ b/lib/src/CvPlugins.php @@ -0,0 +1,94 @@ + 'cv', 'appVersion' => '0.3.50'] + */ + public function init(array $pluginEnv) { + if (getenv('CV_PLUGIN_PATH')) { + $this->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", $pluginName, $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($pluginEnv + [ + '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/lib/src/Util/AliasFilter.php b/lib/src/Util/AliasFilter.php new file mode 100644 index 00000000..2966e335 --- /dev/null +++ b/lib/src/Util/AliasFilter.php @@ -0,0 +1,36 @@ +getFirstArgument(); + if ($firstArg[0] === '@') { + return static::replace($argv, $firstArg, '--site-alias=' . substr($firstArg, 1)); + } + + return $argv; + } + + private static function replace(array $original, $old, $new) { + $pos = array_search($old, $original, TRUE); + $original[$pos] = $new; + return $original; + } + +} diff --git a/lib/src/Util/CvArgvInput.php b/lib/src/Util/CvArgvInput.php new file mode 100644 index 00000000..72f122d5 --- /dev/null +++ b/lib/src/Util/CvArgvInput.php @@ -0,0 +1,25 @@ +originalArgv = $argv; + parent::__construct($argv, $definition); + } + + public function getOriginalArgv(): array { + return $this->originalArgv; + } + +} 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 = []; + } + +} 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/Application.php b/src/Application.php index e56f3fb6..f47a5bb5 100644 --- a/src/Application.php +++ b/src/Application.php @@ -1,19 +1,22 @@ '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. * @@ -25,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'); @@ -33,60 +36,7 @@ public static function version(): ?string { return $v; } } - return NULL; - } - - /** - * Primary entry point for execution of the standalone command. - */ - public static function main($binDir) { - $application = new Application('cv', static::version() ?? 'UNKNOWN'); - - $application->setAutoExit(FALSE); - ErrorHandler::pushHandler(); - $result = $application->run(); - 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."); - } - } - return parent::doRun($input, $output); + return static::DEV_VERSION; } /** @@ -139,12 +89,6 @@ public function createCommands($context = 'default') { 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]); 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 @@ +addListener('myapp.foo', function ($event) { + static::addLog('myapp.foo'); + }); + $d->addListener('myapp.bar', function ($event) { + static::addLog('unrelated'); + }); + $d->dispatch(new CvEvent(['data' => 'yoyo']), 'myapp.foo')->getArguments(); + $this->assertEquals(['myapp.foo'], static::$log); + } + + public function testCallbackTypes() { + $d = new CvDispatcher(); + $d->addListener('myapp.foo', function ($event) { + static::addLog("foo one data=" . $event['data']); + }); + $d->addListener('myapp.foo', __NAMESPACE__ . '\\cvdispatcher_global_func'); + $d->addListener('myapp.foo', [__CLASS__, 'addFooThree']); + + $r = $d->dispatch(new CvEvent(['data' => 'yoyo']), 'myapp.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('myapp.foo', function ($event) { + $event['data'] .= ' first'; + }); + $d->addListener('myapp.foo', function ($event) { + $event['data'] .= ' second'; + }); + $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('myapp.foo', function ($event) { + static::addLog('foo.3.1'); + }, 3); + $d->addListener('myapp.foo', function ($event) { + static::addLog('foo.-200.1'); + }, -200); + $d->addListener('myapp.foo', function ($event) { + static::addLog('foo.1.1'); + }, 1); + $d->addListener('myapp.foo', function ($event) { + static::addLog('foo.2.1'); + }, 2); + $d->addListener('myapp.foo', function ($event) { + static::addLog('foo.1.2'); + }, 1); + $d->addListener('myapp.foo', function ($event) { + static::addLog('foo.1.3'); + }, 1); + $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); + } + + public static function addLog(string $message) { + static::$log[] = $message; + } + + public static function addFooThree($event) { + static::addLog("foo three data=" . $event['data']); + } + +} 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); +} diff --git a/tests/Plugin/HelloPluginTest.php b/tests/Plugin/HelloPluginTest.php new file mode 100644 index 00000000..0fd766ce --- /dev/null +++ b/tests/Plugin/HelloPluginTest.php @@ -0,0 +1,29 @@ +setEnv(['CV_PLUGIN_PATH' => preg_replace(';\.php$;', '', __FILE__)]); + return $process; + } + + public function testRun() { + $output = $this->cvOk('hello'); + $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 new file mode 100644 index 00000000..e454015e --- /dev/null +++ b/tests/Plugin/HelloPluginTest/hello.php @@ -0,0 +1,49 @@ + 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("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')->addArgument('name'); + } + + 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; + } + + }; +}); 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); + } + +}