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
18 changes: 10 additions & 8 deletions doc/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ Cv plugins are PHP files which register event listeners.
```php
// FILE: /etc/cv/plugin/hello-command.php
use Civi\Cv\Cv;
use Civi\Cv\Command\CvCommand;
use CvDeps\Symfony\Component\Console\Input\InputInterface;
use CvDeps\Symfony\Component\Console\Input\InputArgument;
use CvDeps\Symfony\Component\Console\Output\OutputInterface;
use CvDeps\Symfony\Component\Console\Command\Command;

Expand All @@ -16,15 +18,15 @@ if (empty($CV_PLUGIN['protocol']) || $CV_PLUGIN['protocol'] > 1) {
}

Cv::dispatcher()->addListener('cv.app.commands', function($e) {
$e['commands'][] = new class extends Command {
protected function configure() {
$this->setName('hello')->setDescription('Say a greeting');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$output->writeln('Hello there!');

$e['commands'][] = (new CvCommand('hello'))
->setDescription('Say a greeting')
->addArgument('name', InputArgument::REQUIRED, 'Name of the person to greet')
->setCode(function($input, $output) {
$output->writeln('Hello, ' . $input->getArgument('name'));
return 0;
}
};
});

});
```

Expand Down
18 changes: 10 additions & 8 deletions lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,13 @@ For more info about `$options`, see the docblocks.

## Experimental API

Other classes are included, but their contracts are subject to change.

A particularly interesting one is `BootTrait`. This requires `symfony/console`, and it is used by most `cv` subcommands
to achieve common behaviors:

1. `BootTrait` defines certain CLI options (`--level`, `--user`, `--hostname`, etc).
2. `BootTrait` automatically decides between `Bootstrap.php` and `CmsBootstrap.php`.
3. `BootTrait` passes CLI options through to `Bootstrap.php` or `CmsBootstrap.php`.
Other classes are included, but their contracts are subject to change. These
include higher-level helpers for building Symfony Console apps that incorporate
Civi bootstrap behaviors.

* `BootTrait` has previously suggested as an experimentally available API
(circa v0.3.44). It changed significantly (circa v0.3.56), where
`configureBootOptions()` was replaced by `$bootOptions`, `mergeDefaultBootDefinition()`,
and `mergeBootDefinition()`.
* As an alternative, consider the classes `BaseApplication` and `CvCommand` if you aim
to build a tool using Symfony Console and Cv Lib.
12 changes: 10 additions & 2 deletions lib/src/BaseApplication.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
namespace Civi\Cv;

use Civi\Cv\Util\AliasFilter;
use Civi\Cv\Util\BootTrait;
use Civi\Cv\Util\CvArgvInput;
use LesserEvil\ShellVerbosityIsEvil;
use Symfony\Component\Console\Input\InputInterface;
Expand All @@ -24,6 +25,8 @@ public static function main(string $name, ?string $binDir, array $argv) {

try {
$application = new static($name);
Cv::ioStack()->replace('app', $application);
$application->configure();
$argv = AliasFilter::filter($argv);
$result = $application->run(new CvArgvInput($argv), Cv::ioStack()->current('output'));
}
Expand All @@ -38,8 +41,7 @@ public static function main(string $name, ?string $binDir, array $argv) {
exit($result);
}

public function __construct($name = 'UNKNOWN', $version = 'UNKNOWN') {
parent::__construct($name, $version);
public function configure() {
$this->setCatchExceptions(TRUE);
$this->setAutoExit(FALSE);

Expand All @@ -66,6 +68,12 @@ protected function getDefaultInputDefinition() {
$definition = parent::getDefaultInputDefinition();
$definition->addOption(new InputOption('cwd', NULL, InputOption::VALUE_REQUIRED, 'If specified, use the given directory as working directory.'));
$definition->addOption(new InputOption('site-alias', NULL, InputOption::VALUE_REQUIRED, 'Load site connection data based on its alias'));

$c = new class() {
use BootTrait;
};
$c->mergeDefaultBootDefinition($definition);

return $definition;
}

Expand Down
38 changes: 38 additions & 0 deletions lib/src/Command/CvCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php
namespace Civi\Cv\Command;

use Civi\Cv\Util\BootTrait;
use Civi\Cv\Util\OptionCallbackTrait;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

/**
* `CvCommand` is a Symfony `Command` with support for bootstrapping CiviCRM/CMS.
*
* - From end-user POV, the command accepts options like --user, --level, --hostname.
* - From dev POV, the command allows you to implement `execute()` method without needing to
* explicitly boot Civi.
* - From dev POV, you may fine-tune command by changing the $bootOptions / getBootOptions().
*/
class CvCommand extends Command {

use OptionCallbackTrait;
use BootTrait;

public function mergeApplicationDefinition($mergeArgs = TRUE) {
parent::mergeApplicationDefinition($mergeArgs);
$this->mergeBootDefinition($this->getDefinition());
}

/**
* @param \Symfony\Component\Console\Input\InputInterface $input
* @param \Symfony\Component\Console\Output\OutputInterface $output
*/
protected function initialize(InputInterface $input, OutputInterface $output) {
$this->autoboot($input, $output);
parent::initialize($input, $output);
$this->runOptionCallbacks($input, $output);
}

}
7 changes: 7 additions & 0 deletions lib/src/Cv.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ public static function ioStack(): IOStack {
return static::$instances[__FUNCTION__];
}

/**
* @return \CvDeps\Symfony\Component\Console\Application|\Symfony\Component\Console\Application
*/
public static function app() {
return static::ioStack()->current('app');
}

/**
* @return \CvDeps\Symfony\Component\Console\Input\InputInterface|\Symfony\Component\Console\Input\InputInterface
*/
Expand Down
96 changes: 91 additions & 5 deletions lib/src/Util/BootTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,70 @@
*/
trait BootTrait {

public function configureBootOptions($defaultLevel = 'full|cms-full') {
$this->addOption('level', NULL, InputOption::VALUE_REQUIRED, 'Bootstrap level (none,classloader,settings,full,cms-only,cms-full)', $defaultLevel);
$this->addOption('hostname', NULL, InputOption::VALUE_REQUIRED, 'Hostname (for a multisite system)');
$this->addOption('test', 't', InputOption::VALUE_NONE, 'Bootstrap the test database (CIVICRM_UF=UnitTests)');
$this->addOption('user', 'U', InputOption::VALUE_REQUIRED, 'CMS user');
/**
* Describe the expected bootstrap behaviors for this command.
*
* - For most commands, you will want to automatically boot CiviCRM/CMS.
* The default implementation will do this.
* - For some special commands (e.g. core-installer or PHP-script-runner), you may
* want more fine-grained control over when/how the system boots.
*
* @var array
*/
protected $bootOptions = [
// Whether to automatically boot Civi during `initialize()` phase.
'auto' => TRUE,

// Default boot level.
'default' => 'full|cms-full',

// List of all boot levels that are allowed in this command.
'allow' => ['full|cms-full', 'full', 'cms-full', 'settings', 'classloader', 'cms-only', 'none'],
];

/**
* @internal
*/
public function mergeDefaultBootDefinition($definition, $defaultLevel = 'full|cms-full') {
// If we were only dealing with built-in/global commands, then these options could be defined at the command-level.
// However, we also have extension-based commands. The system will boot before we have a chance to discover them.
// By putting these options at the application level, we ensure they will be defined+used.
$definition->addOption(new InputOption('level', NULL, InputOption::VALUE_REQUIRED, 'Bootstrap level (none,classloader,settings,full,cms-only,cms-full)', $defaultLevel));
$definition->addOption(new InputOption('hostname', NULL, InputOption::VALUE_REQUIRED, 'Hostname (for a multisite system)'));
$definition->addOption(new InputOption('test', 't', InputOption::VALUE_NONE, 'Bootstrap the test database (CIVICRM_UF=UnitTests)'));
$definition->addOption(new InputOption('user', 'U', InputOption::VALUE_REQUIRED, 'CMS user'));
}

/**
* @internal
*/
public function mergeBootDefinition($definition) {
$bootOptions = $this->getBootOptions();
$definition->getOption('level')->setDefault($bootOptions['default']);
}

/**
* Evaluate the $bootOptions.
*
* - If we've already booted, do nothing.
* - If the configuration looks reasonable and if we haven't booted yet, then boot().
* - If the configuration looks unreasonable, then abort.
*/
protected function autoboot(InputInterface $input, OutputInterface $output): void {
$bootOptions = $this->getBootOptions();
if (!in_array($input->getOption('level'), $bootOptions['allow'])) {
throw new \LogicException(sprintf("Command called with with level (%s) but only accepts levels (%s)",
$input->getOption('level'), implode(', ', $bootOptions['allow'])));
}

if (!$this->isBooted() && ($bootOptions['auto'] ?? TRUE)) {
$this->boot($input, $output);
}
}

/**
* Start CiviCRM and/or CMS. Respect options like --user and --level.
*/
public function boot(InputInterface $input, OutputInterface $output) {
$logger = $this->bootLogger($output);
$logger->debug('Start');
Expand Down Expand Up @@ -290,4 +347,33 @@ private function bootLogger(OutputInterface $output): InternalLogger {
return new SymfonyConsoleLogger('BootTrait', $output);
}

/**
* @return bool
*/
protected function isBooted() {
return defined('CIVICRM_DSN');
}

protected function assertBooted() {
if (!$this->isBooted()) {
throw new \Exception("Error: This command requires bootstrapping, but the system does not appear to be bootstrapped. Perhaps you set --level=none?");
}
}

/**
* @return array{auto: bool, default: string, allow: string[]}
*/
public function getBootOptions(): array {
return $this->bootOptions;
}

/**
* @param array{auto: bool, default: string, allow: string[]} $bootOptions
* @return $this
*/
public function setBootOptions(array $bootOptions) {
$this->bootOptions = array_merge($this->bootOptions, $bootOptions);
return $this;
}

}
9 changes: 8 additions & 1 deletion lib/src/Util/IOStack.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,19 @@ class IOStack {
*
* @param \Symfony\Component\Console\Input\InputInterface $input
* @param \Symfony\Component\Console\Output\OutputInterface $output
* @param \Symfony\Component\Console\Application|null $app
* @return scalar
* Internal identifier for the stack-frame. ID formatting is not guaranteed.
*/
public function push(\Symfony\Component\Console\Input\InputInterface $input, \Symfony\Component\Console\Output\OutputInterface $output) {
public function push(\Symfony\Component\Console\Input\InputInterface $input, \Symfony\Component\Console\Output\OutputInterface $output, ?\Symfony\Component\Console\Application $app = NULL) {
++static::$id;
$app = $app ?: ($this->stack[0]['app'] ?? NULL);
array_unshift($this->stack, [
'id' => static::$id,
'input' => $input,
'output' => $output,
'io' => new SymfonyStyle($input, $output),
'app' => $app,
]);
return static::$id;
}
Expand Down Expand Up @@ -68,6 +71,10 @@ public function get($id, string $property) {
return NULL;
}

public function replace($property, $value) {
$this->stack[0][$property] = $value;
}

public function reset() {
$this->stack = [];
}
Expand Down
42 changes: 42 additions & 0 deletions lib/src/Util/OptionalOption.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace Civi\Cv\Util;

class OptionalOption {

/**
* Parse an option's data. This is for options where the default behavior
* (of total omission) differs from the activated behavior
* (of an active but unspecified option).
*
* Example, suppose we want these interpretations:
* cv en ==> Means "--refresh=auto"; see $omittedDefault
* cv en -r ==> Means "--refresh=yes"; see $activeDefault
* cv en -r=yes ==> Means "--refresh=yes"
* cv en -r=no ==> Means "--refresh=no"
*
* @param \CvDeps\Symfony\Component\Console\Input\InputInterface|\Symfony\Component\Console\Input\InputInterface $input
* @param array $rawNames
* Ex: array('-r', '--refresh').
* @param string $omittedDefault
* Value to use if option is completely omitted.
* @param string $activeDefault
* Value to use if option is activated without data.
* @return string
*/
public static function parse($input, $rawNames, $omittedDefault, $activeDefault) {
$value = NULL;
foreach ($rawNames as $rawName) {
if ($input->hasParameterOption($rawName)) {
if (NULL === $input->getParameterOption($rawName)) {
return $activeDefault;
}
else {
return $input->getParameterOption($rawName);
}
}
}
return $omittedDefault;
}

}
6 changes: 1 addition & 5 deletions src/Command/AngularHtmlListCommand.php
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
<?php
namespace Civi\Cv\Command;

use Civi\Cv\Util\BootTrait;
use Civi\Cv\Util\StructuredOutputTrait;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class AngularHtmlListCommand extends BaseCommand {
class AngularHtmlListCommand extends CvCommand {

use BootTrait;
use StructuredOutputTrait;

/**
Expand All @@ -34,11 +32,9 @@ protected function configure() {
cv ang:html:list crmUi/*
cv ang:html:list \';(tabset|wizard)\\.html;\'
');
$this->configureBootOptions();
}

protected function execute(InputInterface $input, OutputInterface $output): int {
$this->boot($input, $output);
if (!$input->getOption('user')) {
$output->getErrorOutput()->writeln("<comment>For a full list, try passing --user=[username].</comment>");
}
Expand Down
7 changes: 1 addition & 6 deletions src/Command/AngularHtmlShowCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,12 @@
namespace Civi\Cv\Command;

use Civi\Cv\Util\Process;
use Civi\Cv\Util\BootTrait;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class AngularHtmlShowCommand extends BaseCommand {

use BootTrait;
class AngularHtmlShowCommand extends CvCommand {

/**
* @param string|null $name
Expand Down Expand Up @@ -38,11 +35,9 @@ protected function configure() {
cv ang:html:show crmMailing/BlockMailing.html --diff | colordiff
cv ang:html:show "~/crmMailing/BlockMailing.html"
');
$this->configureBootOptions();
}

protected function execute(InputInterface $input, OutputInterface $output): int {
$this->boot($input, $output);
if (!$input->getOption('user')) {
$output->getErrorOutput()->writeln("<comment>For a full list, try passing --user=[username].</comment>");
}
Expand Down
Loading