Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions bin/cv
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
99 changes: 99 additions & 0 deletions doc/plugins.md
Original file line number Diff line number Diff line change
@@ -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
```

<!--
Doesn't currently support project-specific plugins. This may be trickier.

After loading the global plugins, `cv` reads the the `cv.yml` and then loads any *local plugins* (i.e. *project-specific* plugins).

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.

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
137 changes: 137 additions & 0 deletions lib/src/BaseApplication.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?php
namespace Civi\Cv;

use Civi\Cv\Util\AliasFilter;
use Civi\Cv\Util\CvArgvInput;
use LesserEvil\ShellVerbosityIsEvil;
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;

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();

$preBootInput = new CvArgvInput($argv);
$preBootOutput = new ConsoleOutput();
Cv::ioStack()->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);
});
}

}
115 changes: 115 additions & 0 deletions lib/src/Cv.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

namespace Civi\Cv;

use Civi\Cv\Util\IOStack;

class Cv {

protected static $instances = [];

// /**
// * Get a PSR-16 cache service (`SimpleCache`).
// *
// * This is cache is file-based and user-scoped (e.g. `~/.cache/cv`).
// * Don't expect it to be high-performance...
// *
// * NOTE: At time of writing, this is not used internally - but can be used by a plugin.
// *
// * @param string $namespace
// * @return \Psr\SimpleCache\CacheInterface
// */
// public static function cache($namespace = 'default') {
// if (!isset(self::$instances["cache.$namespace"])) {
// if (getenv('XDG_CACHE_HOME')) {
// $dir = getenv('XDG_CACHE_HOME');
// }
// elseif (getenv('HOME')) {
// $dir = getenv('HOME') . '/.cache';
// }
// else {
// throw new \RuntimeException("Failed to determine cache location");
// }
// $fsCache = new FilesystemAdapter($namespace, 600, $dir . DIRECTORY_SEPARATOR . 'cv');
// // In symfony/cache~3.x, the class name is weird.
// self::$instances["cache.$namespace"] = new Psr6Cache($fsCache);
// }
// return self::$instances["cache.$namespace"];
// }

/**
* Get the system-wide event-dispatcher.
*
* @return \Civi\Cv\CvDispatcher
*/
public static function dispatcher() {
if (!isset(self::$instances['dispatcher'])) {
self::$instances['dispatcher'] = new CvDispatcher();
}
return self::$instances['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($eventName, array $data) {
$event = new CvEvent($data);
self::dispatcher()->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'];
}

}
Loading