├── .gitignore ├── README.md ├── bin ├── cv └── cv2 ├── box.json ├── composer.json ├── composer.lock ├── doc ├── develop.md ├── download.md └── plugins.md ├── lib ├── .gitrepo ├── README.md ├── composer.json ├── plugin │ ├── alias-cmd.php │ └── basic-alias.php └── src │ ├── BaseApplication.php │ ├── Bootstrap.php │ ├── BuildkitReader.php │ ├── CmsBootstrap.php │ ├── Command │ └── CvCommand.php │ ├── Config.php │ ├── Cv.php │ ├── CvDispatcher.php │ ├── CvEvent.php │ ├── CvPlugins.php │ ├── ErrorHandler.php │ ├── Log │ ├── InternalLogger.php │ ├── Logger.php │ ├── StderrLogger.php │ └── SymfonyConsoleLogger.php │ ├── PharOut │ ├── PharOut.php │ └── PharPolicyTrait.php │ ├── SiteConfigReader.php │ ├── Top.php │ └── Util │ ├── AliasFilter.php │ ├── BootTrait.php │ ├── CvArgvInput.php │ ├── Filesystem.php │ ├── FilesystemTrait.php │ ├── IOStack.php │ ├── OptionalOption.php │ └── SimulateWeb.php ├── nix ├── buildkit-update.sh └── buildkit.nix ├── patches ├── psysh-0.11-php84.diff ├── psysh-0.11-php84.txt ├── scc-CompletionHandler.diff └── scc-CompletionHandler.txt ├── phpunit.xml.dist ├── scoper.inc.php ├── scripts ├── build.sh ├── check-phar.php ├── releaser.php └── run-tests.sh ├── shell.nix ├── src ├── Application.php ├── ClassAliases.php ├── Command │ ├── AngularHtmlListCommand.php │ ├── AngularHtmlShowCommand.php │ ├── AngularModuleListCommand.php │ ├── Api4Command.php │ ├── ApiBatchCommand.php │ ├── ApiCommand.php │ ├── BootCommand.php │ ├── CliCommand.php │ ├── CoreCheckReqCommand.php │ ├── CoreInstallCommand.php │ ├── CoreUninstallCommand.php │ ├── DebugContainerCommand.php │ ├── DebugDispatcherCommand.php │ ├── EditCommand.php │ ├── EvalCommand.php │ ├── ExtensionDisableCommand.php │ ├── ExtensionDownloadCommand.php │ ├── ExtensionEnableCommand.php │ ├── ExtensionListCommand.php │ ├── ExtensionUninstallCommand.php │ ├── ExtensionUpgradeDbCommand.php │ ├── FillCommand.php │ ├── FlushCommand.php │ ├── HttpCommand.php │ ├── PathCommand.php │ ├── PipeCommand.php │ ├── QueueNextCommand.php │ ├── ScriptCommand.php │ ├── SettingGetCommand.php │ ├── SettingRevertCommand.php │ ├── SettingSetCommand.php │ ├── ShowCommand.php │ ├── SqlCliCommand.php │ ├── StatusCommand.php │ ├── UpgradeCommand.php │ ├── UpgradeDbCommand.php │ ├── UpgradeDlCommand.php │ ├── UpgradeGetCommand.php │ ├── UpgradeReportCommand.php │ └── UrlCommand.php ├── Encoder.php ├── Exception │ ├── ProcessErrorException.php │ └── QueueTaskException.php ├── ExtensionPolyfill │ ├── PfHelper.php │ ├── PfQueueDownloader.php │ └── PfQueueTasks.php └── Util │ ├── AbstractPlusParser.php │ ├── Api4ArgEncoder.php │ ├── Api4ArgParser.php │ ├── ArrayUtil.php │ ├── CliEditor.php │ ├── ConsoleQueueRunner.php │ ├── ConsoleSubprocessQueueRunner.php │ ├── Cv.php │ ├── Datasource.php │ ├── DebugDispatcherTrait.php │ ├── ExtensionTrait.php │ ├── HeadlessDownloader.php │ ├── OptionCallbackTrait.php │ ├── Process.php │ ├── PsrLogger.php │ ├── Rand.php │ ├── Relativizer.php │ ├── SettingArgParser.php │ ├── SettingCodec.php │ ├── SettingTrait.php │ ├── SetupCommandTrait.php │ ├── StructuredOutputTrait.php │ ├── UrlCommandTrait.php │ └── VerboseApi.php └── tests ├── CivilTestCase.php ├── Command ├── AngularHtmlListCommandTest.php ├── AngularHtmlShowCommandTest.php ├── AngularModuleListCommandTest.php ├── ApiBatchCommandTest.php ├── ApiCommandTest.php ├── BaseExtensionCommandTest.php ├── BootCommandTest.php ├── CoreLifecycleTest.php ├── DebugContainerCommandTest.php ├── DebugDispatcherCommandTest.php ├── EvalCommandTest.php ├── ExtensionBareDownloadTest.php ├── ExtensionLifecycleTest.php ├── ExtensionListCommandTest.php ├── FillCommandTest.php ├── FlushCommandTest.php ├── HttpCommandTest.php ├── PathCommandTest.php ├── ScriptCommandTest.php ├── SettingLifecycleTest.php ├── ShowCommandTest.php ├── SqlCliCommandTest.php ├── StatusCommandTest.php ├── UpgradeGetCommandTest.php ├── UpgradeReportCommandTest.php ├── UrlCommandTest.php ├── hello-args.php └── hello-world.php ├── CvDispatcherTest.php ├── CvTestTrait.php ├── Plugin ├── AliasPluginTest.php ├── AliasPluginTest │ └── dummy-alias.php ├── FluentHelloPluginTest.php ├── FluentHelloPluginTest │ └── hello.php ├── HelloPluginTest.php └── HelloPluginTest │ └── hello.php ├── TopHelperTest.php ├── Util ├── AliasFilterTest.php ├── Api4ArgEncoderTest.php ├── Api4ArgParserTest.php ├── FilesystemTest.php ├── OptionalOptionTest.php └── ProcessTest.php ├── bootstrap.php └── fixtures └── org.example.cvtest ├── LICENSE.txt ├── cvtest.php ├── info.xml └── make.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /.idea 3 | *~ 4 | .composer-downloads 5 | /bin/box 6 | /bin/cv.phar 7 | /bin/phpunit 8 | /bin/php-parse 9 | /bin/psysh 10 | /bin/var-dump-server 11 | /extern/ 12 | /lib/vendor 13 | /tmp-lib 14 | /build/ 15 | -------------------------------------------------------------------------------- /bin/cv: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | setVerbosity(\Symfony\Component\Console\Output\OutputInterface::VERBOSITY_DEBUG); 26 | \Civi\Cv\CmsBootstrap::singleton()->addOptions([ 27 | 'output' => $output, 28 | ]); 29 | 30 | $output->writeln("[cv2] Boot CMS"); 31 | \Civi\Cv\CmsBootstrap::singleton()->bootCms(); 32 | 33 | $output->writeln("[cv2] Boot CiviCRM"); 34 | \Civi\Cv\CmsBootstrap::singleton()->bootCivi(); 35 | 36 | $output->writeln("Hello from CiviCRM v" . CRM_Utils_System::version()); 37 | -------------------------------------------------------------------------------- /box.json: -------------------------------------------------------------------------------- 1 | { 2 | "chmod": "0755", 3 | "directories": [ 4 | "lib/src", 5 | "lib/plugin", 6 | "src" 7 | ], 8 | "finder": [ 9 | { 10 | "name": "*.php", 11 | "exclude": ["phpunit", "Tests", "Test", "tests", "test", "composer-patches"], 12 | "in": "vendor" 13 | } 14 | ], 15 | "compactors": ["KevinGH\\Box\\Compactor\\Php", "KevinGH\\Box\\Compactor\\PhpScoper"], 16 | "git-version": "package_version", 17 | "main": "bin/cv", 18 | "output": "bin/cv.phar", 19 | "stub": true 20 | } 21 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "civicrm/cv", 3 | "description": "CLI tool for CiviCRM", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Tim Otten", 8 | "email": "totten@civicrm.org" 9 | } 10 | ], 11 | "require": { 12 | "php": ">=7.3.0", 13 | "ext-json": "*", 14 | "cweagans/composer-patches": "~1.0", 15 | "lesser-evil/shell-verbosity-is-evil": "~1.0", 16 | "symfony/console": "~5.4", 17 | "symfony/process": "~5.4", 18 | "psr/log": "~1.1 || ~2.0 || ~3.0", 19 | "psy/psysh": "@stable", 20 | "stecman/symfony-console-completion": "^0.11.0" 21 | }, 22 | "autoload": { 23 | "psr-4": { 24 | "Civi\\Cv\\": ["lib/src/", "src/"] 25 | } 26 | }, 27 | "replace": { 28 | "civicrm/cv-lib": "self.version" 29 | }, 30 | "bin": [ 31 | "bin/cv" 32 | ], 33 | "config": { 34 | "platform": { 35 | "php": "7.3.0" 36 | }, 37 | "bin-dir": "bin", 38 | "allow-plugins": { 39 | "civicrm/composer-downloads-plugin": true, 40 | "cweagans/composer-patches": true 41 | } 42 | }, 43 | "extra": { 44 | "patches": { 45 | "stecman/symfony-console-completion": { 46 | "Fix warnings on PHP 8.4": "patches/scc-CompletionHandler.diff" 47 | }, 48 | "psy/psysh": { 49 | "Fix warnings on PHP 8.4": "patches/psysh-0.11-php84.diff" 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lib/.gitrepo: -------------------------------------------------------------------------------- 1 | ; DO NOT EDIT (unless you know what you are doing) 2 | ; 3 | ; This subdirectory is a git "subrepo", and this file is maintained by the 4 | ; git-subrepo command. See https://github.com/git-commands/git-subrepo#readme 5 | ; 6 | [subrepo] 7 | remote = git@github.com:civicrm/cv-lib.git 8 | branch = master 9 | commit = 34cd212d371d541c53f180e7382880dcaeb01d37 10 | method = merge 11 | cmdver = 0.4.1 12 | parent = 15c6b262bb1bf4b160aa2753f1dadae500c67247 13 | -------------------------------------------------------------------------------- /lib/README.md: -------------------------------------------------------------------------------- 1 | # cv-lib 2 | 3 | `cv-lib` is a subpackage provided by `cv`. It defines the essential core of `cv` -- locating and booting CiviCRM. 4 | 5 | The canonical home for developing this code is in [civicrm/cv](https://github.com/civicrm/cv). It will be periodically published to the read-only 6 | mirror [civicrm/cv-lib](https://github.com/civicrm/cv-lib) to facilitate usage by other projects. 7 | 8 | ## Installation 9 | 10 | ```bash 11 | composer require civicrm/cv-lib 12 | ``` 13 | 14 | ## Primary API 15 | 16 | The library provides a handful of supported classes: 17 | 18 | * `Civi\Cv\CmsBootstrap` supports the standard boot protocol. In this protocol, we search for a recognized UF/CMS, start 19 | that, and then start CiviCRM. The advantage of this protocol is that it is more representative of a typical 20 | HTTP-request. (Events and add-ons supported by UF/CMS and CRM will tend to work more normally.) 21 | 22 | Basic usage: 23 | 24 | ```php 25 | Civi\Cv\CmsBootstrap::singleton()->bootCms()->bootCivi(); 26 | ``` 27 | 28 | Or you can pass in options: 29 | 30 | ```php 31 | $options = [...]; 32 | Civi\Cv\CmsBootstrap::singleton() 33 | ->addOptions($options) 34 | ->bootCms() 35 | ->bootCivi(); 36 | ``` 37 | 38 | End-users may fine-tune the behavior by setting `CIVICRM_BOOT` (as documented in `cv`). 39 | 40 | * `Civi\Cv\Bootstrap` supports the legacy boot protocol. In this protocol, we search for `civicrm.settings.php` and 41 | start CiviCRM. Finally, we use `civicrm-core` API's to start the associated UF/CMS. 42 | 43 | Basic usage: 44 | 45 | ```php 46 | $options = [...]; 47 | \Civi\Cv\Bootstrap::singleton()->boot($options); 48 | \CRM_Core_Config::singleton(); 49 | \CRM_Utils_System::loadBootStrap([], FALSE); 50 | ``` 51 | 52 | End-users may fine-tune the behavior by setting `CIVICRM_SETTING` (as documented in `cv`). 53 | 54 | Both bootstrap mechanisms accept an optional set of hints and overrides. 55 | 56 | For example, by default, `cv-lib` will print errors to STDERR, but you can override the 57 | handling of messages: 58 | 59 | ```php 60 | // Disable all output 61 | $options['log'] = new \Psr\Log\NullLogger(); 62 | 63 | // Enable verbose logging to STDOUT/STDERR 64 | $options['log'] = new \Civi\Cv\Log\StderrLogger('Bootstrap', TRUE); 65 | 66 | // Use bridge between psr/log and symfony/console 67 | $options['log'] = new \Symfony\Component\Console\Logger\ConsoleLogger($output); 68 | 69 | // Use the console logger from cv cli. (Requires symfony/console. Looks a bit prettier.) 70 | public function execute(InputInterface $input, OutputInterface $output) { 71 | ... 72 | $options['output'] = $output; 73 | ... 74 | } 75 | ``` 76 | 77 | For more info about `$options`, see the docblocks. 78 | 79 | ## Experimental API 80 | 81 | Other classes are included, but their contracts are subject to change. These 82 | include higher-level helpers for building Symfony Console apps that incorporate 83 | Civi bootstrap behaviors. 84 | 85 | * `BootTrait` has previously suggested as an experimentally available API 86 | (circa v0.3.44). It changed significantly (circa v0.3.56), where 87 | `configureBootOptions()` was replaced by `$bootOptions`, `mergeDefaultBootDefinition()`, 88 | and `mergeBootDefinition()`. 89 | * As an alternative, consider the classes `BaseApplication` and `CvCommand` if you aim 90 | to build a tool using Symfony Console and Cv Lib. 91 | -------------------------------------------------------------------------------- /lib/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "civicrm/cv-lib", 3 | "description": "Bootstrap library for CiviCRM CLI tools", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Tim Otten", 8 | "email": "totten@civicrm.org" 9 | } 10 | ], 11 | "require": { 12 | "php": ">=7.3.0", 13 | "ext-json": "*" 14 | }, 15 | "autoload": { 16 | "psr-4": { 17 | "Civi\\Cv\\": ["src/"] 18 | } 19 | }, 20 | "config": { 21 | "platform": { 22 | "php": "7.3.0" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/src/BuildkitReader.php: -------------------------------------------------------------------------------- 1 | toAbsolutePath($settingsFile); 17 | $parts = explode('/', str_replace('\\', '/', $settingsFile)); 18 | while (!empty($parts)) { 19 | $last = array_pop($parts); 20 | $basePath = implode('/', $parts); 21 | $shFile = "$basePath/$last.sh"; 22 | if (is_dir("$basePath/$last") && file_exists($shFile)) { 23 | // Does it look vaguely like a buildkit config file? 24 | if (preg_match('/ADMIN_USER=/', file_get_contents($shFile))) { 25 | return $shFile; 26 | } 27 | } 28 | } 29 | return NULL; 30 | } 31 | 32 | /** 33 | * Parse the key-value paris in buildkit .sh config file. 34 | * 35 | * @param string $shFile 36 | * @return array 37 | */ 38 | public static function readShFile($shFile) { 39 | $lines = explode("\n", file_get_contents($shFile)); 40 | $result = array(); 41 | foreach ($lines as $line) { 42 | if (empty($line) || $line[0] == '#') { 43 | continue; 44 | } 45 | if (preg_match('/^([A-Z0-9_]+)=\"(.*)\"$/', $line, $matches)) { 46 | $result[$matches[1]] = stripcslashes($matches[2]); 47 | } 48 | else { 49 | throw new \RuntimeException("Malformed line [$line]"); 50 | } 51 | } 52 | return $result; 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /lib/src/Command/CvCommand.php: -------------------------------------------------------------------------------- 1 | mergeBootDefinition($this->getDefinition()); 26 | } 27 | 28 | /** 29 | * @param \Symfony\Component\Console\Input\InputInterface $input 30 | * @param \Symfony\Component\Console\Output\OutputInterface $output 31 | */ 32 | protected function initialize(InputInterface $input, OutputInterface $output) { 33 | $this->autoboot($input, $output); 34 | parent::initialize($input, $output); 35 | $this->runOptionCallbacks($input, $output); 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /lib/src/Config.php: -------------------------------------------------------------------------------- 1 | update(self::getFileName(), function ($rawIn) use ($filter) { 35 | $data = empty($rawIn) ? array() : json_decode($rawIn, TRUE); 36 | $data = call_user_func($filter, $data); 37 | return Encoder::encode($data, 'json-pretty'); 38 | }); 39 | } 40 | 41 | /** 42 | * @return string 43 | */ 44 | public static function getFileName() { 45 | if (getenv('CV_CONFIG')) { 46 | // The user has specifically told us where to go. 47 | return getenv('CV_CONFIG'); 48 | } 49 | 50 | // We have to figure out where to go. There are a couple plausible locations... 51 | $candidates = []; 52 | if (getenv('XDG_CONFIG_HOME')) { 53 | $candidates[] = getenv('XDG_CONFIG_HOME') . '/.cv.json'; 54 | } 55 | if (getenv('HOME')) { 56 | $candidates[] = getenv('HOME') . '/.cv.json'; 57 | } 58 | 59 | // Prefer the first extant config file... 60 | foreach ($candidates as $candidate) { 61 | if (file_exists($candidate)) { 62 | return $candidate; 63 | } 64 | } 65 | 66 | // Or if there is no extant file, then use the first plausible suggestion... 67 | if (isset($candidates[0])) { 68 | return $candidates[0]; 69 | } 70 | 71 | throw new \RuntimeException("Failed to determine file path for 'cv.json'."); 72 | 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /lib/src/CvDispatcher.php: -------------------------------------------------------------------------------- 1 | listeners[$name])) { 23 | $activeListeners = array_merge($activeListeners, $this->listeners[$name]); 24 | } 25 | } 26 | 27 | usort($activeListeners, function ($a, $b) { 28 | if ($a['priority'] !== $b['priority']) { 29 | return $a['priority'] - $b['priority']; 30 | } 31 | else { 32 | return $a['natPriority'] - $b['natPriority']; 33 | } 34 | }); 35 | foreach ($activeListeners as $listener) { 36 | call_user_func($listener['callback'], $event); 37 | } 38 | 39 | return $event; 40 | } 41 | 42 | public function addListener(string $eventName, $callback, int $priority = 0): void { 43 | static $natPriority = 0; 44 | $natPriority++; 45 | $id = $this->getCallbackId($callback); 46 | $this->listeners[$eventName][$id] = ['callback' => $callback, 'priority' => $priority, 'natPriority' => $natPriority]; 47 | } 48 | 49 | public function removeListener(string $eventName, $callback) { 50 | $id = $this->getCallbackId($callback); 51 | unset($this->listeners[$eventName][$id]); 52 | } 53 | 54 | /** 55 | * @param $callback 56 | * @return string 57 | */ 58 | protected function getCallbackId($callback): string { 59 | if (is_string($callback)) { 60 | return $callback; 61 | } 62 | elseif (is_array($callback)) { 63 | return implode('::', $callback); 64 | } 65 | else { 66 | return spl_object_hash($callback); 67 | } 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /lib/src/CvEvent.php: -------------------------------------------------------------------------------- 1 | arguments = $arguments; 24 | } 25 | 26 | /** 27 | * Get argument by key. 28 | * 29 | * @param string $key Key 30 | * @return mixed Contents of array key 31 | * 32 | * @throws \InvalidArgumentException if key is not found 33 | */ 34 | public function getArgument($key) { 35 | if ($this->hasArgument($key)) { 36 | return $this->arguments[$key]; 37 | } 38 | 39 | throw new \InvalidArgumentException(sprintf('Argument "%s" not found.', $key)); 40 | } 41 | 42 | /** 43 | * Add argument to event. 44 | * 45 | * @param string $key Argument name 46 | * @param mixed $value Value 47 | * 48 | * @return $this 49 | */ 50 | public function setArgument($key, $value) { 51 | $this->arguments[$key] = $value; 52 | 53 | return $this; 54 | } 55 | 56 | /** 57 | * Getter for all arguments. 58 | * 59 | * @return array 60 | */ 61 | public function getArguments() { 62 | return $this->arguments; 63 | } 64 | 65 | /** 66 | * Set args property. 67 | * 68 | * @param array $args Arguments 69 | * 70 | * @return $this 71 | */ 72 | public function setArguments(array $args = []) { 73 | $this->arguments = $args; 74 | 75 | return $this; 76 | } 77 | 78 | /** 79 | * Has argument. 80 | * 81 | * @param string $key Key of arguments array 82 | * 83 | * @return bool 84 | */ 85 | public function hasArgument($key) { 86 | return \array_key_exists($key, $this->arguments); 87 | } 88 | 89 | /** 90 | * IteratorAggregate for iterating over the object like an array. 91 | * 92 | * @return \ArrayIterator 93 | */ 94 | public function getIterator(): \Traversable { 95 | return new \ArrayIterator($this->arguments); 96 | } 97 | 98 | /** 99 | * ArrayAccess for argument getter. 100 | * 101 | * @param string $key Array key 102 | * @return mixed 103 | * @throws \InvalidArgumentException if key does not exist in $this->args 104 | */ 105 | #[\ReturnTypeWillChange] 106 | public function &offsetGet($offset) { 107 | return $this->arguments[$offset]; 108 | } 109 | 110 | /** 111 | * ArrayAccess for argument setter. 112 | * 113 | * @param string $offset Array key to set 114 | * @param mixed $value Value 115 | */ 116 | public function offsetSet($offset, $value): void { 117 | $this->setArgument($offset, $value); 118 | } 119 | 120 | /** 121 | * ArrayAccess for unset argument. 122 | * 123 | * @param string $offset Array key 124 | */ 125 | public function offsetUnset($offset): void { 126 | if ($this->hasArgument($offset)) { 127 | unset($this->arguments[$offset]); 128 | } 129 | } 130 | 131 | /** 132 | * ArrayAccess has argument. 133 | * 134 | * @param string $offset Array key 135 | * @return bool 136 | */ 137 | public function offsetExists($offset): bool { 138 | return $this->hasArgument($offset); 139 | } 140 | 141 | public function isPropagationStopped(): bool { 142 | return $this->propagationStopped; 143 | } 144 | 145 | public function setPropagationStopped(bool $propagationStopped): void { 146 | $this->propagationStopped = $propagationStopped; 147 | } 148 | 149 | } 150 | -------------------------------------------------------------------------------- /lib/src/CvPlugins.php: -------------------------------------------------------------------------------- 1 | 'cv', 'appVersion' => '0.3.50'] 23 | */ 24 | public function init(array $pluginEnv) { 25 | if (getenv('CV_PLUGIN_PATH')) { 26 | $this->paths = explode(PATH_SEPARATOR, getenv('CV_PLUGIN_PATH')); 27 | } 28 | else { 29 | $this->paths = ['/etc/cv/plugin', '/usr/local/share/cv/plugin', '/usr/share/cv/plugin']; 30 | if (getenv('HOME')) { 31 | array_unshift($this->paths, getenv('HOME') . '/.cv/plugin'); 32 | } 33 | elseif (getenv('USERPROFILE')) { 34 | array_unshift($this->paths, getenv('USERPROFILE') . '/.cv/plugin'); 35 | } 36 | if (getenv('XDG_CONFIG_HOME')) { 37 | array_unshift($this->paths, getenv('XDG_CONFIG_HOME') . '/cv/plugin'); 38 | } 39 | } 40 | 41 | // Always load internal plugins 42 | $this->paths['builtin'] = dirname(__DIR__) . '/plugin'; 43 | 44 | $this->plugins = []; 45 | foreach ($this->paths as $path) { 46 | if (file_exists($path) && is_dir($path)) { 47 | foreach ($this->findFiles($path, '/\.php$/') as $file) { 48 | $pluginName = preg_replace(';(\d+-)?(.*)(@\w+)?\.php;', '\\2', basename($file)); 49 | if ($pluginName === basename($file)) { 50 | throw new \RuntimeException("Malformed plugin name: $file"); 51 | } 52 | if (!isset($this->plugins[$pluginName])) { 53 | $this->plugins[$pluginName] = $file; 54 | } 55 | else { 56 | fprintf(STDERR, "WARNING: Plugin %s has multiple definitions (%s, %s)\n", $pluginName, $file, $this->plugins[$pluginName]); 57 | } 58 | } 59 | } 60 | } 61 | 62 | ksort($this->plugins); 63 | foreach ($this->plugins as $pluginName => $pluginFile) { 64 | // FIXME: Refactor so that you can add more plugins post-boot `load("/some/glob*.php")` 65 | $this->load($pluginEnv + [ 66 | 'protocol' => 1, 67 | 'name' => $pluginName, 68 | 'file' => $pluginFile, 69 | ]); 70 | } 71 | } 72 | 73 | /** 74 | * @param array $CV_PLUGIN 75 | * Description of the plugin being loaded. 76 | * Keys: 77 | * - version: Protocol version (ex: "1") 78 | * - name: Basenemae of the plugin (eg `hello.php`) 79 | * - file: Logic filename (eg `/etc/cv/plugin/hello.php`) 80 | * @return void 81 | */ 82 | protected function load(array $CV_PLUGIN) { 83 | include $CV_PLUGIN['file']; 84 | } 85 | 86 | /** 87 | * @return string[] 88 | */ 89 | public function getPaths(): array { 90 | return $this->paths; 91 | } 92 | 93 | /** 94 | * @return array 95 | */ 96 | public function getPlugins(): array { 97 | return $this->plugins; 98 | } 99 | 100 | private function findFiles(string $path, string $regex): array { 101 | // NOTE: scandir() works better than glob() in PHAR context. 102 | $files = preg_grep($regex, scandir($path)); 103 | return array_map(function ($f) use ($path) { 104 | return "$path/$f"; 105 | }, $files); 106 | } 107 | 108 | } 109 | -------------------------------------------------------------------------------- /lib/src/ErrorHandler.php: -------------------------------------------------------------------------------- 1 | 0) { 34 | $error = error_get_last(); 35 | if (isset($error['type']) && ($error['type'] & (E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR))) { 36 | // Something - like a bad eval() - interrupted normal execution. 37 | // Make sure the status code reflects that. 38 | exit(255); 39 | } 40 | } 41 | } 42 | 43 | public static function onError($errorLevel, $message, $filename, $line) { 44 | if ($errorLevel & error_reporting()) { 45 | $errorType = static::getErrorTypes()[$errorLevel] ?: "Unknown[$errorLevel]"; 46 | fprintf(STDERR, "[%s] %s at %s:%d\n", $errorType, $message, $filename, $line); 47 | return TRUE; 48 | } 49 | else { 50 | return FALSE; 51 | } 52 | } 53 | 54 | /** 55 | * @param \Throwable $exception 56 | */ 57 | public static function onException($exception) { 58 | if (isset(static::$renderer)) { 59 | call_user_func(static::$renderer, $exception); 60 | } 61 | else { 62 | fprintf(STDERR, "Exception: %s (%s)\n%s", $exception->getMessage(), get_class($exception), $exception->getTraceAsString()); 63 | } 64 | exit(255); 65 | } 66 | 67 | /** 68 | * @param callable|null $renderer 69 | */ 70 | public static function setRenderer(?callable $renderer): void { 71 | self::$renderer = $renderer; 72 | } 73 | 74 | protected static function getErrorTypes(): array { 75 | return array_merge( 76 | [ 77 | E_ERROR => 'PHP Error', 78 | E_WARNING => 'PHP Warning', 79 | E_PARSE => 'PHP Parse Error', 80 | E_NOTICE => 'PHP Notice', 81 | E_CORE_ERROR => 'PHP Core Error', 82 | E_CORE_WARNING => 'PHP Core Warning', 83 | E_COMPILE_ERROR => 'PHP Compile Error', 84 | E_COMPILE_WARNING => 'PHP Compile Warning', 85 | E_USER_ERROR => 'PHP User Error', 86 | E_USER_WARNING => 'PHP User Warning', 87 | E_USER_NOTICE => 'PHP User Notice', 88 | E_RECOVERABLE_ERROR => 'PHP Recoverable Fatal Error', 89 | E_DEPRECATED => 'PHP Deprecation', 90 | E_USER_DEPRECATED => 'PHP User Deprecation', 91 | ], 92 | version_compare(phpversion(), '8.4', '<') ? [constant('E_STRICT') => 'PHP Strict Warning'] : [] 93 | // https://wiki.php.net/rfc/deprecations_php_8_4#remove_e_strict_error_level_and_deprecate_e_strict_constant 94 | // In theory, once cv shifts to 8.x only, we can simplify this. 95 | ); 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /lib/src/Log/Logger.php: -------------------------------------------------------------------------------- 1 | verbose = $verbose; 17 | } 18 | 19 | public function log($level, $message, array $context = []) { 20 | if ($this->isAnomolous($level) || $this->verbose) { 21 | $template = "[%s:%s] %s\n"; 22 | fprintf(STDERR, $template, $this->topic, $level, $this->interpolate($message, $context)); 23 | } 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /lib/src/Log/SymfonyConsoleLogger.php: -------------------------------------------------------------------------------- 1 | output = $output; 27 | $this->verbosityLevelMap = [ 28 | 'emergency' => OutputInterface::VERBOSITY_NORMAL, 29 | 'alert' => OutputInterface::VERBOSITY_NORMAL, 30 | 'critical' => OutputInterface::VERBOSITY_NORMAL, 31 | 'error' => OutputInterface::VERBOSITY_NORMAL, 32 | 'warning' => OutputInterface::VERBOSITY_NORMAL, 33 | 'notice' => OutputInterface::VERBOSITY_VERBOSE, 34 | 'info' => OutputInterface::VERBOSITY_VERY_VERBOSE, 35 | 'debug' => OutputInterface::VERBOSITY_DEBUG, 36 | ]; 37 | } 38 | 39 | public function log($level, $message, array $context = []) { 40 | $output = $this->output; 41 | 42 | if ($output instanceof ConsoleOutputInterface) { 43 | $output = $output->getErrorOutput(); 44 | } 45 | 46 | if ($this->isAnomolous($level)) { 47 | $template = '[%s:%s] %s'; 48 | } 49 | else { 50 | $template = '[%s:%s] %s'; 51 | } 52 | 53 | if ($output->getVerbosity() >= $this->verbosityLevelMap[$level]) { 54 | $output->writeln(sprintf($template, $this->topic, $level, $this->interpolate($message, $context)), 55 | $this->verbosityLevelMap[$level]); 56 | } 57 | } 58 | 59 | protected function decorateInterpolatedValue(string $value): string { 60 | return "$value"; 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /lib/src/PharOut/PharOut.php: -------------------------------------------------------------------------------- 1 | withAssertion(new class() implements \TYPO3\PharStreamWrapper\Assertable { 55 | use PharPolicyTrait; 56 | }) 57 | ); 58 | if (in_array('phar', stream_get_wrappers())) { 59 | stream_wrapper_unregister('phar'); 60 | stream_wrapper_register('phar', \TYPO3\PharStreamWrapper\PharStreamWrapper::class); 61 | } 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /lib/src/PharOut/PharPolicyTrait.php: -------------------------------------------------------------------------------- 1 | mainFile === NULL) { 33 | $this->mainFile = $this->findMainFile(); 34 | } 35 | 36 | $baseFile = Helper::determineBaseFile($path); 37 | if ($baseFile === NULL) { 38 | throw new Exception(sprintf('Failed to identify origin of "%s"', $path), 1535198703); 39 | } 40 | 41 | if (($baseFile === $this->mainFile) || (strtolower(pathinfo($baseFile, PATHINFO_EXTENSION)) === 'phar')) { 42 | return TRUE; 43 | } 44 | 45 | throw new Exception(sprintf('File "%s" does not resolve to approved PHAR location.', $path), 1535198703); 46 | } 47 | 48 | /** 49 | * Determine the 'main' script that started this process. 50 | * 51 | * @return string|null 52 | */ 53 | private function findMainFile() { 54 | if (!in_array(PHP_SAPI, ['cli', 'phpdbg'])) { 55 | return NULL; 56 | } 57 | 58 | $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); 59 | do { 60 | $caller = array_pop($backtrace); 61 | } while (empty($caller['file']) && !empty($backtrace)); 62 | return isset($caller['file']) ? Helper::determineBaseFile($caller['file']) : NULL; 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /lib/src/Top.php: -------------------------------------------------------------------------------- 1 | getFirstArgument(); 23 | if ($firstArg[0] === '@') { 24 | return static::replace($argv, $firstArg, '--site-alias=' . substr($firstArg, 1)); 25 | } 26 | 27 | return $argv; 28 | } 29 | 30 | private static function replace(array $original, $old, $new) { 31 | $pos = array_search($old, $original, TRUE); 32 | $original[$pos] = $new; 33 | return $original; 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /lib/src/Util/CvArgvInput.php: -------------------------------------------------------------------------------- 1 | originalArgv = $argv; 18 | parent::__construct($argv, $definition); 19 | } 20 | 21 | public function getOriginalArgv(): array { 22 | return $this->originalArgv; 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /lib/src/Util/FilesystemTrait.php: -------------------------------------------------------------------------------- 1 | 9 | * @license MIT 10 | */ 11 | trait FilesystemTrait { 12 | 13 | /** 14 | * @var string|null 15 | */ 16 | private static $lastError; 17 | 18 | /** 19 | * Returns whether the file path is an absolute path. 20 | * 21 | * @param string $file A file path 22 | * @return bool 23 | */ 24 | public function isAbsolutePath($file) { 25 | return '' !== (string) $file && (strspn($file, '/\\', 0, 1) 26 | || (\strlen($file) > 3 && ctype_alpha($file[0]) 27 | && ':' === $file[1] 28 | && strspn($file, '/\\', 2, 1) 29 | ) 30 | || NULL !== parse_url($file, \PHP_URL_SCHEME) 31 | ); 32 | } 33 | 34 | /** 35 | * Removes files or directories. 36 | * 37 | * @param string|iterable $files A filename, an array of files, or a \Traversable instance to remove 38 | * 39 | * @throws \RuntimeException When removal fails 40 | */ 41 | public function remove($files) { 42 | if ($files instanceof \Traversable) { 43 | $files = iterator_to_array($files, FALSE); 44 | } 45 | elseif (!\is_array($files)) { 46 | $files = [$files]; 47 | } 48 | $files = array_reverse($files); 49 | foreach ($files as $file) { 50 | if (is_link($file)) { 51 | // See https://bugs.php.net/52176 52 | if (!(self::box('unlink', $file) || '\\' !== \DIRECTORY_SEPARATOR || self::box('rmdir', $file)) && file_exists($file)) { 53 | throw new \RuntimeException(sprintf('Failed to remove symlink "%s": ', $file) . self::$lastError); 54 | } 55 | } 56 | elseif (is_dir($file)) { 57 | $this->remove(new \FilesystemIterator($file, \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::SKIP_DOTS)); 58 | 59 | if (!self::box('rmdir', $file) && file_exists($file)) { 60 | throw new \RuntimeException(sprintf('Failed to remove directory "%s": ', $file) . self::$lastError); 61 | } 62 | } 63 | elseif (!self::box('unlink', $file) && (str_contains(self::$lastError, 'Permission denied') || file_exists($file))) { 64 | throw new \RuntimeException(sprintf('Failed to remove file "%s": ', $file) . self::$lastError); 65 | } 66 | } 67 | } 68 | 69 | /** 70 | * @param callable $func 71 | * File I/O function from PHP stdlib 72 | * @param mixed ...$args 73 | * @return mixed 74 | */ 75 | private static function box(callable $func, ...$args) { 76 | self::$lastError = NULL; 77 | set_error_handler(__CLASS__ . '::handleError'); 78 | try { 79 | $result = $func(...$args); 80 | restore_error_handler(); 81 | 82 | return $result; 83 | } 84 | catch (\Throwable $e) { 85 | } 86 | restore_error_handler(); 87 | 88 | throw $e; 89 | } 90 | 91 | /** 92 | * @internal 93 | */ 94 | public static function handleError(int $type, string $msg) { 95 | self::$lastError = $msg; 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /lib/src/Util/IOStack.php: -------------------------------------------------------------------------------- 1 | stack[0]['app'] ?? NULL); 31 | array_unshift($this->stack, [ 32 | 'id' => static::$id, 33 | 'input' => $input, 34 | 'output' => $output, 35 | 'io' => new SymfonyStyle($input, $output), 36 | 'app' => $app, 37 | ]); 38 | return static::$id; 39 | } 40 | 41 | public function pop(): array { 42 | return array_shift($this->stack); 43 | } 44 | 45 | /** 46 | * Get a current property of the current (top) stack-frame. 47 | * 48 | * @param string $property 49 | * One of: 'input', 'output', 'io', 'id' 50 | * @return mixed 51 | */ 52 | public function current(string $property) { 53 | return $this->stack[0][$property]; 54 | } 55 | 56 | /** 57 | * Lookup a property from a particular stack-frame. 58 | * 59 | * @param scalar $id 60 | * Internal identifier for the stack-frame. 61 | * @param string $property 62 | * One of: 'input', 'output', 'io', 'id' 63 | * @return mixed|null 64 | */ 65 | public function get($id, string $property) { 66 | foreach ($this->stack as $item) { 67 | if ($item['id'] === $id) { 68 | return $item[$property]; 69 | } 70 | } 71 | return NULL; 72 | } 73 | 74 | public function replace($property, $value) { 75 | $this->stack[0][$property] = $value; 76 | } 77 | 78 | public function reset() { 79 | $this->stack = []; 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /lib/src/Util/OptionalOption.php: -------------------------------------------------------------------------------- 1 | Means "--refresh=auto"; see $omittedDefault 14 | * cv en -r ==> Means "--refresh=yes"; see $activeDefault 15 | * cv en -r=yes ==> Means "--refresh=yes" 16 | * cv en -r=no ==> Means "--refresh=no" 17 | * 18 | * @param \CvDeps\Symfony\Component\Console\Input\InputInterface|\Symfony\Component\Console\Input\InputInterface $input 19 | * @param array $rawNames 20 | * Ex: array('-r', '--refresh'). 21 | * @param string $omittedDefault 22 | * Value to use if option is completely omitted. 23 | * @param string $activeDefault 24 | * Value to use if option is activated without data. 25 | * @return string 26 | */ 27 | public static function parse($input, $rawNames, $omittedDefault, $activeDefault) { 28 | $value = NULL; 29 | foreach ($rawNames as $rawName) { 30 | if ($input->hasParameterOption($rawName)) { 31 | if (NULL === $input->getParameterOption($rawName)) { 32 | return $activeDefault; 33 | } 34 | else { 35 | return $input->getParameterOption($rawName); 36 | } 37 | } 38 | } 39 | return $omittedDefault; 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /lib/src/Util/SimulateWeb.php: -------------------------------------------------------------------------------- 1 | $value) { 20 | $_SERVER[$key] = $value; 21 | } 22 | } 23 | 24 | if (ord($_SERVER['SCRIPT_NAME']) != 47) { 25 | $_SERVER['SCRIPT_NAME'] = '/' . $_SERVER['SCRIPT_NAME']; 26 | } 27 | } 28 | 29 | public static function convertUrlToCgiVars(?string $url): array { 30 | if (strpos($url, '://') === FALSE) { 31 | throw new \LogicException("convertUrlToCgiVars() expects a URL"); 32 | } 33 | 34 | $parts = parse_url($url); 35 | $result = []; 36 | $result['SERVER_NAME'] = $parts['host']; 37 | if (!empty($parts['port'])) { 38 | $result['HTTP_HOST'] = $parts['host'] . ':' . $parts['port']; 39 | $result['SERVER_PORT'] = $parts['port']; 40 | } 41 | else { 42 | $result['HTTP_HOST'] = $parts['host']; 43 | $result['SERVER_PORT'] = $parts['scheme'] === 'http' ? 80 : 443; 44 | } 45 | if ($parts['scheme'] === 'https') { 46 | $result['HTTPS'] = 'on'; 47 | } 48 | return $result; 49 | } 50 | 51 | public static function detectEnvUrl(): ?string { 52 | if ($host = static::detectEnvHost()) { 53 | return static::detectEnvScheme() . '://' . $host; 54 | } 55 | return NULL; 56 | } 57 | 58 | /** 59 | * If the user has environment-variables like HTTP_HOST, take that as a sign of 60 | * the intended host. 61 | * 62 | * @return string|null 63 | */ 64 | public static function detectEnvHost(): ?string { 65 | if (array_key_exists('HTTP_HOST', $_SERVER) && strpos($_SERVER['HTTP_HOST'], '//') === FALSE) { 66 | $url = $_SERVER['HTTP_HOST']; 67 | if (array_key_exists('HTTP_PORT', $_SERVER)) { 68 | $url .= $_SERVER['HTTP_PORT']; 69 | } 70 | return $url; 71 | } 72 | return NULL; 73 | } 74 | 75 | public static function detectEnvScheme(): ?string { 76 | return (($_SERVER['SERVER_PORT'] ?? NULL) === 443 || ($_SERVER['HTTPS'] ?? NULL) === 'on') ? 'https' : 'http'; 77 | } 78 | 79 | public static function prependDefaultScheme(?string $url): string { 80 | if ($url === NULL || $url === '') { 81 | return $url; 82 | } 83 | elseif (strpos($url, '://') !== FALSE) { 84 | return $url; 85 | } 86 | else { 87 | return static::detectEnvScheme() . '://' . $url; 88 | } 89 | } 90 | 91 | public static function localhost(): string { 92 | return static::prependDefaultScheme('localhost'); 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /nix/buildkit-update.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | { # https://stackoverflow.com/a/21100710 3 | 4 | ## Re-generate the buildkit.nix file - with the current 'master' branch. 5 | 6 | set -e 7 | 8 | if [ ! -f "nix/buildkit.nix" ]; then 9 | echo >&2 "Must run in project root" 10 | exit 1 11 | fi 12 | 13 | now=$( date -u '+%Y-%m-%d %H:%M %Z' ) 14 | commit=$( git ls-remote https://github.com/civicrm/civicrm-buildkit.git | awk '/refs\/heads\/master$/ { print $1 }' ) 15 | url="https://github.com/civicrm/civicrm-buildkit/archive/${commit}.tar.gz" 16 | hash=$( nix-prefetch-url "$url" --type sha256 --unpack ) 17 | 18 | function render_file() { 19 | echo "{ pkgs ? import {} }:" 20 | echo "" 21 | echo "## Get civicrm-buildkit from github." 22 | echo "## Based on \"master\" branch circa $now" 23 | echo "import (pkgs.fetchzip {" 24 | echo " url = \"$url\";" 25 | echo " sha256 = \"$hash\";" 26 | echo "})" 27 | echo 28 | echo "## Get a local copy of civicrm-buildkit. (Useful for developing patches.)" 29 | echo "# import ((builtins.getEnv \"HOME\") + \"/buildkit/default.nix\")" 30 | echo "# import ((builtins.getEnv \"HOME\") + \"/bknix/default.nix\")" 31 | } 32 | render_file > nix/buildkit.nix 33 | 34 | exit 35 | } 36 | -------------------------------------------------------------------------------- /nix/buildkit.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import {} }: 2 | 3 | ## Get civicrm-buildkit from github. 4 | ## Based on "master" branch circa 2024-02-26 04:30 UTC 5 | import (pkgs.fetchzip { 6 | url = "https://github.com/civicrm/civicrm-buildkit/archive/d6f6b8dd2d5944c35cd78cb319fef21673214b35.tar.gz"; 7 | sha256 = "02p2yzdfgv66a2zf8i36h6pjfi78wnp92m3klij7fqbfd9mpvi5a"; 8 | }) 9 | 10 | ## Get a local copy of civicrm-buildkit. (Useful for developing patches.) 11 | # import ((builtins.getEnv "HOME") + "/buildkit/default.nix") 12 | # import ((builtins.getEnv "HOME") + "/bknix/default.nix") 13 | -------------------------------------------------------------------------------- /patches/psysh-0.11-php84.txt: -------------------------------------------------------------------------------- 1 | At time of writing, `psy/psysh@0.11.x` is the last version to support PHP 7.x. 2 | Add the PHP 8.4 fixes to it. 3 | -------------------------------------------------------------------------------- /patches/scc-CompletionHandler.diff: -------------------------------------------------------------------------------- 1 | diff --git a/src/CompletionHandler.php b/src/CompletionHandler.php 2 | index 236687b..871838e 100644 3 | --- a/src/CompletionHandler.php 4 | +++ b/src/CompletionHandler.php 5 | @@ -40,7 +40,7 @@ class CompletionHandler 6 | */ 7 | private $commandWordIndex; 8 | 9 | - public function __construct(Application $application, CompletionContext $context = null) 10 | + public function __construct(Application $application, ?CompletionContext $context = null) 11 | { 12 | $this->application = $application; 13 | $this->context = $context; 14 | 15 | -------------------------------------------------------------------------------- /patches/scc-CompletionHandler.txt: -------------------------------------------------------------------------------- 1 | At time of writing, `stecman/symfony-console-completion@0.11.0` is the last 2 | version to support PHP 7.x. Add the PHP 8.4 fixes to it. 3 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | ./src 17 | 18 | 19 | 20 | 21 | ./tests/ 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /scoper.inc.php: -------------------------------------------------------------------------------- 1 | 'Cvphar', 5 | 'exclude-namespaces' => [ 6 | // Provided by cv 7 | 'CvDeps', 8 | 9 | // Provided by civicrm 10 | 'Civi', 11 | 'Guzzle', 12 | 'Symfony\Component\DependencyInjection', 13 | 14 | // Drupal8+ bootstrap 15 | 'Drupal', 16 | 'Symfony\\Component\\HttpFoundation', 17 | 'Symfony\\Component\\Routing', 18 | 19 | // Joomla bootstrap 20 | 'TYPO3\\PharStreamWrapper', 21 | ], 22 | 'exclude-classes' => [ 23 | '/^(CRM_|HTML_|DB_|Log_)/', 24 | '/^PEAR_(Error|Exception)/', 25 | 'DB', 26 | 'Log', 27 | 'JFactory', 28 | 'Civi', 29 | 'Drupal', 30 | ], 31 | 'exclude-functions' => [ 32 | '/^civicrm_/', 33 | '/^wp_.*/', 34 | '/^(drupal|backdrop|user|module)_/', 35 | 't', 36 | ], 37 | 'exclude-files' => [ 38 | 'vendor/symfony/polyfill-php80/Resources/stubs/Stringable.php' 39 | ], 40 | 41 | // Do not generate wrappers/aliases for `civicrm_api()` etc or various CMS-booting functions. 42 | 'expose-global-functions' => FALSE, 43 | ]; 44 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ## Determine the absolute path of the directory with the file 4 | ## usage: absdirname 5 | function absdirname() { 6 | pushd $(dirname $0) >> /dev/null 7 | pwd 8 | popd >> /dev/null 9 | } 10 | 11 | SCRDIR=$(absdirname "$0") 12 | PRJDIR=$(dirname "$SCRDIR") 13 | OUTFILE="$PRJDIR/bin/cv.phar" 14 | set -e 15 | 16 | pushd "$PRJDIR" >> /dev/null 17 | composer install --prefer-dist --no-progress --no-suggest --no-dev 18 | box compile -v 19 | php scripts/check-phar.php "$OUTFILE" 20 | popd >> /dev/null 21 | -------------------------------------------------------------------------------- /scripts/check-phar.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | &2 "Unrecognized SUITE=$SUITE" ; hasSuite=1 ; ;; 84 | esac 85 | fi 86 | 87 | if [ -z "$hasSuite" ]; then 88 | usage 1>&2 89 | exit 1 90 | fi 91 | 92 | if [ -n "$compile" ]; then 93 | ./scripts/build.sh 94 | fi 95 | (cd "$CV_TEST_BUILD" && XDEBUG_MODE=off civibuild restore) 96 | 97 | export CV_TEST_BINARY 98 | "$PHPUNIT" "${passthru[@]}" 99 | } 100 | 101 | ################################################ 102 | main "$@" 103 | } 104 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | /** 2 | * This shell is suitable for compiling PHAR executables.... and not much else. 3 | * 4 | * Ex: `nix-shell --run ./scripts/build.sh` 5 | */ 6 | 7 | { pkgs ? import {} }: 8 | 9 | let 10 | 11 | buildkit = (import ./nix/buildkit.nix) { inherit pkgs; }; 12 | 13 | in 14 | 15 | pkgs.mkShell { 16 | nativeBuildInputs = buildkit.profiles.base ++ [ 17 | 18 | (buildkit.pins.v2305.php82.buildEnv { 19 | extraConfig = '' 20 | memory_limit=-1 21 | ''; 22 | }) 23 | 24 | buildkit.pkgs.box 25 | buildkit.pkgs.composer 26 | buildkit.pkgs.pogo 27 | buildkit.pkgs.phpunit8 28 | buildkit.pkgs.phpunit9 29 | 30 | pkgs.bash-completion 31 | ]; 32 | shellHook = '' 33 | source ${pkgs.bash-completion}/etc/profile.d/bash_completion.sh 34 | ''; 35 | } 36 | -------------------------------------------------------------------------------- /src/ClassAliases.php: -------------------------------------------------------------------------------- 1 | setName('ang:html:list') 23 | ->setAliases(array()) 24 | ->setDescription('List Angular HTML files') 25 | ->configureOutputOptions(['tabular' => TRUE, 'fallback' => 'list', 'defaultColumns' => 'file', 'shortcuts' => TRUE]) 26 | ->addArgument('filter', InputArgument::OPTIONAL, 27 | 'Filter by filename. For regex filtering, use semicolon delimiter.') 28 | ->setHelp('List Angular HTML files 29 | 30 | Examples: 31 | cv ang:html:list 32 | cv ang:html:list crmUi/* 33 | cv ang:html:list \';(tabset|wizard)\\.html;\' 34 | '); 35 | } 36 | 37 | protected function execute(InputInterface $input, OutputInterface $output): int { 38 | if (!$input->getOption('user')) { 39 | $output->getErrorOutput()->writeln("For a full list, try passing --user=[username]."); 40 | } 41 | 42 | $this->sendStandardTable($this->find($input)); 43 | return 0; 44 | } 45 | 46 | /** 47 | * Find extensions matching the input args. 48 | * 49 | * @param \Symfony\Component\Console\Input\InputInterface $input 50 | * @return array 51 | */ 52 | protected function find($input) { 53 | $regex = $input->getArgument('filter') ? $this->createRegex($input->getArgument('filter')) : NULL; 54 | $ang = \Civi::service('angular'); 55 | $rows = array(); 56 | 57 | foreach ($ang->getModules() as $name => $module) { 58 | $partials = $ang->getPartials($name); 59 | foreach ($partials as $file => $html) { 60 | $rows[] = array( 61 | 'file' => preg_replace(';^~/;', '', $file), 62 | 'module' => $name, 63 | 'ext' => $module['ext'], 64 | ); 65 | } 66 | } 67 | 68 | $rows = array_filter($rows, function ($row) use ($regex) { 69 | if ($regex) { 70 | if (!preg_match($regex, $row['file'])) { 71 | return FALSE; 72 | } 73 | } 74 | return TRUE; 75 | }); 76 | 77 | return $rows; 78 | } 79 | 80 | protected function createRegex($filterExpr) { 81 | if ($filterExpr[0] === ';') { 82 | return $filterExpr; 83 | } 84 | // $filterExpr = preg_replace(';^~/;', '', $filterExpr); 85 | $regex = preg_quote($filterExpr, ';'); 86 | $regex = str_replace('\\*', '[^/]*', $regex); 87 | $regex = ";^$regex.*$;"; 88 | return $regex; 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /src/Command/AngularModuleListCommand.php: -------------------------------------------------------------------------------- 1 | setName('ang:module:list') 23 | ->setAliases(array()) 24 | ->setDescription('List Angular modules') 25 | ->configureOutputOptions(['tabular' => TRUE, 'fallback' => 'table', 'defaultColumns' => 'name,basePages,requires', 'shortcuts' => TRUE]) 26 | ->addArgument('regex', InputArgument::OPTIONAL, 27 | 'Filter extensions by full key or short name') 28 | ->setHelp('List Angular modules 29 | 30 | Examples: 31 | cv ang:module:list 32 | cv ang:module:list /crmUi/ 33 | cv ang:module:list --columns=name,ext,extDir 34 | cv ang:module:list \'/crmMail/\' --user=admin --columns=extDir,css 35 | cv ang:module:list --columns=name,js,css --out=json-pretty 36 | '); 37 | } 38 | 39 | protected function execute(InputInterface $input, OutputInterface $output): int { 40 | if (!$input->getOption('user')) { 41 | $output->getErrorOutput()->writeln("For a full list, try passing --user=[username]."); 42 | } 43 | 44 | $this->sendStandardTable($this->find($input)); 45 | return 0; 46 | } 47 | 48 | /** 49 | * Find extensions matching the input args. 50 | * 51 | * @param \Symfony\Component\Console\Input\InputInterface $input 52 | * @return array 53 | */ 54 | protected function find($input) { 55 | $regex = $input->getArgument('regex'); 56 | $ang = \Civi::service('angular'); 57 | $rows = array(); 58 | 59 | foreach ($ang->getModules() as $name => $module) { 60 | $resources = array(); 61 | foreach (array('js', 'partials', 'css', 'settings') as $key) { 62 | if (!empty($module[$key])) { 63 | $resources[] = sprintf("%s(%d)", $key, count($module[$key])); 64 | } 65 | } 66 | 67 | if (!isset($module['basePages'])) { 68 | $basePages = 'civicrm/a'; 69 | } 70 | elseif (empty($module['basePages'])) { 71 | $basePages = '(as-needed)'; 72 | } 73 | else { 74 | $basePages = implode(", ", $module['basePages']); 75 | } 76 | 77 | $rows[] = array( 78 | 'name' => $name, 79 | 'ext' => $module['ext'], 80 | 'extDir' => \CRM_Core_Resources::singleton()->getPath($module['ext'], NULL), 81 | 'resources' => implode(', ', $resources), 82 | 'basePages' => $basePages, 83 | 'js' => isset($module['js']) ? implode(", ", $module['js']) : '', 84 | 'css' => isset($module['css']) ? implode(", ", $module['css']) : '', 85 | 'partials' => isset($module['partials']) ? implode(", ", $module['partials']) : '', 86 | 'requires' => isset($module['requires']) ? implode(', ', $module['requires']) : '', 87 | ); 88 | } 89 | 90 | $rows = array_filter($rows, function ($row) use ($regex) { 91 | if ($regex) { 92 | if (!preg_match($regex, $row['ext']) && !preg_match($regex, $row['name'])) { 93 | return FALSE; 94 | } 95 | } 96 | return TRUE; 97 | }); 98 | 99 | return $rows; 100 | } 101 | 102 | } 103 | -------------------------------------------------------------------------------- /src/Command/BootCommand.php: -------------------------------------------------------------------------------- 1 | setName('php:boot') 12 | ->setDescription('Generate PHP bootstrap code'); 13 | } 14 | 15 | protected function execute(InputInterface $input, OutputInterface $output): int { 16 | switch ($input->getOption('level')) { 17 | case 'classloader': 18 | $code = implode("\n", [ 19 | sprintf('$GLOBALS["civicrm_root"] = %s;', var_export(rtrim($GLOBALS["civicrm_root"], '/'), 1)), 20 | 'require_once $GLOBALS["civicrm_root"] . "/CRM/Core/ClassLoader.php";', 21 | '\CRM_Core_ClassLoader::singleton()->register();', 22 | ]); 23 | break; 24 | 25 | case 'settings': 26 | $code = \Civi\Cv\Bootstrap::singleton()->generate() 27 | . '\CRM_Core_Config::singleton(FALSE);'; 28 | break; 29 | 30 | case 'full': 31 | $code = \Civi\Cv\Bootstrap::singleton()->generate() 32 | . '\CRM_Core_Config::singleton();' 33 | . '\CRM_Utils_System::loadBootStrap(array(), FALSE);'; 34 | break; 35 | 36 | case 'cms-full': 37 | $code = \Civi\Cv\CmsBootstrap::singleton()->generate(['bootCms', 'bootCivi']); 38 | break; 39 | 40 | case 'none': 41 | $code = ''; 42 | break; 43 | 44 | default: 45 | throw new \Exception("Cannot generate boot instructions for given boot level."); 46 | } 47 | 48 | $output->writeln('/*BEGINPHP*/'); 49 | $output->writeln($code); 50 | $output->writeln('/*ENDPHP*/'); 51 | return 0; 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/Command/CliCommand.php: -------------------------------------------------------------------------------- 1 | setName('cli') 17 | ->setDescription('Load interactive command line'); 18 | } 19 | 20 | protected function execute(InputInterface $input, OutputInterface $output): int { 21 | $cv = new Application(); 22 | $sh = new \Psy\Shell(); 23 | $sh->addCommands($cv->createCommands()); 24 | // When I try making a new matcher, it doesn't seem to get called. 25 | //$sh->addTabCompletionMatchers(array( 26 | // new ApiMatcher(), 27 | //)); 28 | $sh->run(); 29 | return 0; 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/Command/CoreCheckReqCommand.php: -------------------------------------------------------------------------------- 1 | setName('core:check-req') 20 | ->setDescription('Check installation requirements') 21 | ->configureOutputOptions(['tabular' => TRUE, 'fallback' => 'table', 'shortcuts' => TRUE, 'defaultColumns' => 'severity,section,name,message']) 22 | ->addOption('filter-warnings', 'w', InputOption::VALUE_NONE, 'Show warnings') 23 | ->addOption('filter-errors', 'e', InputOption::VALUE_NONE, 'Show errors') 24 | ->addOption('filter-infos', 'i', InputOption::VALUE_NONE, 'Show info') 25 | ->configureSetupOptions() 26 | ->setHelp(' 27 | Check whether installation requirements are met. 28 | 29 | Example: Show all checks 30 | $ cv core:check-req 31 | 32 | Example: Show only errors 33 | $ cv core:check-req -e 34 | 35 | Example: Show warnings and errors 36 | $ cv core:check-req -we 37 | '); 38 | } 39 | 40 | public function getBootOptions(): array { 41 | return ['default' => 'none', 'allow' => ['none']]; 42 | } 43 | 44 | protected function execute(InputInterface $input, OutputInterface $output): int { 45 | $filterSeverities = $this->parseFilter($input); 46 | 47 | $showBootMsgsByDefault = in_array($input->getOption('out'), ['table', 'pretty']); 48 | $setup = $this->bootSetupSubsystem($input, $output, $showBootMsgsByDefault ? 0 : OutputInterface::VERBOSITY_VERBOSE); 49 | $reqs = $setup->checkRequirements(); 50 | $messages = array_filter($reqs->getMessages(), function ($m) use ($filterSeverities) { 51 | return in_array($m['severity'], $filterSeverities); 52 | }); 53 | $this->sendStandardTable($messages); 54 | return $reqs->getErrors() ? 1 : 0; 55 | } 56 | 57 | /** 58 | * @param \Symfony\Component\Console\Input\InputInterface $input 59 | * @return array 60 | */ 61 | protected function parseFilter(InputInterface $input) { 62 | $filterSeverities = array(); 63 | if ($input->getOption('filter-warnings')) { 64 | $filterSeverities[] = 'warning'; 65 | } 66 | if ($input->getOption('filter-errors')) { 67 | $filterSeverities[] = 'error'; 68 | } 69 | if ($input->getOption('filter-infos')) { 70 | $filterSeverities[] = 'info'; 71 | } 72 | if ($filterSeverities === array()) { 73 | $filterSeverities = array('warning', 'error', 'info'); 74 | } 75 | return $filterSeverities; 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /src/Command/CoreUninstallCommand.php: -------------------------------------------------------------------------------- 1 | setName('core:uninstall') 22 | ->setDescription('Purge CiviCRM schema and settings files') 23 | ->configureSetupOptions() 24 | ->addOption('force', 'f', InputOption::VALUE_NONE, 'Remove without any prompt or confirmation') 25 | ->configureOutputOptions() 26 | ->setHelp(' 27 | Purge CiviCRM schema and settings files 28 | 29 | TIP: If you have a special system configuration, it may help to pass the same 30 | options for "core:uninstall" as the preceding "core:install". 31 | '); 32 | } 33 | 34 | public function getBootOptions(): array { 35 | return ['default' => 'none', 'allow' => ['none']]; 36 | } 37 | 38 | protected function execute(InputInterface $input, OutputInterface $output): int { 39 | $setup = $this->bootSetupSubsystem($input, $output); 40 | 41 | $debugEvent = OptionalOption::parse($input, ['--debug-event'], NULL, ''); 42 | if ($debugEvent !== NULL) { 43 | $eventNames = $this->findEventNames($setup->getDispatcher(), $debugEvent); 44 | $this->printEventListeners($output, $setup->getDispatcher(), $eventNames); 45 | return 0; 46 | } 47 | 48 | $installed = $setup->checkInstalled(); 49 | if (!$installed->isDatabaseInstalled() && !$installed->isSettingInstalled()) { 50 | $output->writeln("CiviCRM does not appear to be installed."); 51 | return 0; 52 | } 53 | 54 | if ($installed->isDatabaseInstalled()) { 55 | $output->writeln(sprintf("Found civicrm_* database tables in %s", $setup->getModel()->db['database'])); 56 | } 57 | 58 | if ($installed->isSettingInstalled()) { 59 | $output->writeln(sprintf("Found %s in %s", basename($setup->getModel()->settingsPath), dirname($setup->getModel()->settingsPath))); 60 | } 61 | 62 | if (!$input->getOption('force')) { 63 | $output->writeln(''); 64 | $helper = $this->getHelper('question'); 65 | $question = new ConfirmationQuestion('Are you sure want to purge the CiviCRM database and data files? Data may be permanently destroyed. (y/N) ', FALSE); 66 | if (!$helper->ask($input, $output, $question)) { 67 | $output->writeln("Aborted"); 68 | return 1; 69 | } 70 | } 71 | 72 | if ($installed->isDatabaseInstalled()) { 73 | $output->writeln(sprintf("Removing civicrm_* database tables in %s", $setup->getModel()->db['database'])); 74 | $setup->uninstallDatabase(); 75 | } 76 | 77 | if ($installed->isSettingInstalled()) { 78 | $output->writeln(sprintf("Removing %s from %s", basename($setup->getModel()->settingsPath), dirname($setup->getModel()->settingsPath))); 79 | $setup->uninstallFiles(); 80 | } 81 | 82 | return 0; 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /src/Command/DebugDispatcherCommand.php: -------------------------------------------------------------------------------- 1 | setName('event') 16 | ->setDescription('Inspect events and listeners') 17 | ->addArgument('event', InputArgument::OPTIONAL, 'An event name or regex') 18 | // ->addOption('out', NULL, InputArgument::OPTIONAL, 'Specify return format (json,none,php,pretty,shell)', \Civi\Cv\Encoder::getDefaultFormat()) 19 | // ->configureOutputOptions() 20 | ->setHelp(' 21 | Dump the list of event listeners 22 | 23 | Examples: 24 | cv debug:event-dispatcher 25 | cv debug:event-dispatcher actionSchedule.getMappings 26 | cv debug:event-dispatcher /^actionSchedule/ 27 | '); 28 | } 29 | 30 | protected function initialize(InputInterface $input, OutputInterface $output) { 31 | define('CIVICRM_CONTAINER_CACHE', 'never'); 32 | $output->getErrorOutput()->writeln('The debug command ignores the container cache.'); 33 | parent::initialize($input, $output); 34 | } 35 | 36 | protected function execute(InputInterface $input, OutputInterface $output): int { 37 | $container = \Civi::container(); 38 | 39 | /* 40 | * Workaround: Ensure that API kernel has registered its event listeners. 41 | * 42 | * At time of writing (Mar 2017), civicrm-core has its list of 43 | * event-listeners in two places: Container::createEventDispatcher() and 44 | * Container::createApiKernel(). That's ugly. In the long term, both of 45 | * these should be replaced with a more distributed+consistent mechanism 46 | * (e.g. `services.yml`). However, in the mean-time, we need to ensure 47 | * that the API kernel (if applicable) has its chance to register listeners. 48 | */ 49 | if ($container->has('civi_api_kernel')) { 50 | $container->get('civi_api_kernel'); 51 | } 52 | 53 | $dispatcher = $container->get('dispatcher'); 54 | $eventFilter = $input->getArgument('event'); 55 | $eventNames = $this->findEventNames($dispatcher, $eventFilter); 56 | $this->printEventListeners($output, $dispatcher, $eventNames); 57 | return 0; 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /src/Command/EditCommand.php: -------------------------------------------------------------------------------- 1 | setName('vars:edit') 25 | ->setDescription('Edit configuration values for this build'); 26 | } 27 | 28 | public function __construct($name = NULL) { 29 | parent::__construct($name); 30 | $this->editor = new CliEditor(); 31 | $this->editor->setValidator(function ($file) { 32 | $data = json_decode(file_get_contents($file)); 33 | if ($data === NULL) { 34 | return array( 35 | FALSE, 36 | '// The JSON document was malformed. Please resolve syntax errors and then remove this message.', 37 | ); 38 | } 39 | else { 40 | return array(TRUE, ''); 41 | } 42 | }); 43 | } 44 | 45 | protected function execute(InputInterface $input, OutputInterface $output): int { 46 | $config = Config::read(); 47 | $oldSiteData = empty($config['sites'][CIVICRM_SETTINGS_PATH]) ? array() : $config['sites'][CIVICRM_SETTINGS_PATH]; 48 | $oldJson = Encoder::encode($oldSiteData, 'json-pretty'); 49 | $newJson = $this->editor->editBuffer($oldJson); 50 | $newSiteData = json_decode($newJson); 51 | 52 | print "NEW DATA\n\n====\n$newJson\n====\n"; 53 | 54 | // Config::update(function ($config) use ($newSiteData) { 55 | // $config['sites'][CIVICRM_SETTINGS_PATH] = $newSiteData; 56 | // return $config; 57 | // }); 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /src/Command/EvalCommand.php: -------------------------------------------------------------------------------- 1 | setName('php:eval') 17 | ->setAliases(array('ev')) 18 | ->setDescription('Evaluate a snippet of PHP code') 19 | ->addArgument('code') 20 | ->addOption('out', NULL, InputArgument::OPTIONAL, 'Specify return format (auto,' . implode(',', Encoder::getFormats()) . ')', 'auto') 21 | ->setHelp(' 22 | Evaluate a snippet of PHP code 23 | 24 | Examples: 25 | cv ev \'civicrm_api3("System", "flush", array());\' 26 | cv ev \'if (rand(0,10)<5) echo "heads\n"; else echo "tails\n";\' 27 | 28 | When reading data, you may use "return": 29 | cv ev \'return CRM_Utils_System::version()\' 30 | cv ev \'return CRM_Utils_System::version()\' --out=shell 31 | cv ev \'return CRM_Utils_System::version()\' --out=json 32 | 33 | If the output format is set to "auto". This will be produce silent output -- unless 34 | you use a "return" statement. In that case, it will use the default (' . \Civi\Cv\Encoder::getDefaultFormat() . '). 35 | 36 | NOTE: To change the default output format, set CV_OUTPUT. 37 | '); 38 | } 39 | 40 | protected function execute(InputInterface $input, OutputInterface $output): int { 41 | if ($input->getOption('out') === 'auto') { 42 | $hasReturn = preg_match('/^\s*return[ \t\r\n]/', $input->getArgument('code')) 43 | || preg_match('/[;\{]\s*return[ \t\r\n]/', $input->getArgument('code')); 44 | $input->setOption('out', $hasReturn ? \Civi\Cv\Encoder::getDefaultFormat() : 'none'); 45 | } 46 | 47 | $value = eval($input->getArgument('code') . ';'); 48 | $this->sendResult($input, $output, $value); 49 | return 0; 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/Command/ExtensionDisableCommand.php: -------------------------------------------------------------------------------- 1 | setName('ext:disable') 24 | ->setAliases(array('dis')) 25 | ->setDescription('Disable an extension') 26 | ->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")') 27 | ->setHelp('Disable an extension 28 | 29 | Examples: 30 | cv ext:disable org.example.foobar 31 | cv dis foobar 32 | 33 | Note: 34 | Beginning circa CiviCRM v4.2+, it has been recommended that extensions 35 | include a unique long name ("org.example.foobar") and a unique short 36 | name ("foobar"). However, short names are not strongly guaranteed. 37 | 38 | This subcommand does not output parseable data. For parseable output, 39 | consider using `cv api extension.disable`. 40 | '); 41 | } 42 | 43 | protected function execute(InputInterface $input, OutputInterface $output): int { 44 | [$foundKeys, $missingKeys] = $this->parseKeys($input, $output); 45 | 46 | // Uninstall what's recognized or what looks like an ext key. 47 | $disableKeys = array_merge($foundKeys, preg_grep('/\./', $missingKeys)); 48 | $missingKeys = preg_grep('/\./', $missingKeys, PREG_GREP_INVERT); 49 | 50 | foreach ($missingKeys as $key) { 51 | $output->writeln("Ignoring unrecognized extension \"$key\""); 52 | } 53 | foreach ($disableKeys as $key) { 54 | $output->writeln("Disabling extension \"$key\""); 55 | } 56 | 57 | $result = VerboseApi::callApi3Success('Extension', 'disable', array( 58 | 'keys' => $disableKeys, 59 | )); 60 | return empty($result['is_error']) ? 0 : 1; 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/Command/ExtensionEnableCommand.php: -------------------------------------------------------------------------------- 1 | setName('ext:enable') 25 | ->setAliases(['en', 'ext:install']) 26 | ->setDescription('Enable an extension') 27 | ->addOption('refresh', 'r', InputOption::VALUE_NONE, 'Refresh the local list of extensions (Default: Only refresh on cache-miss)') 28 | ->addOption('ignore-missing', NULL, InputOption::VALUE_NONE, 'If a requested extension is missing, skip it') 29 | ->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")') 30 | ->setHelp('Enable an extension 31 | 32 | Examples: 33 | cv ext:enable org.example.foobar 34 | cv en foobar 35 | 36 | Note: 37 | Beginning circa CiviCRM v4.2+, it has been recommended that extensions 38 | include a unique long name ("org.example.foobar") and a unique short 39 | name ("foobar"). However, short names are not strongly guaranteed. 40 | 41 | This subcommand does not output parseable data. For parseable output, 42 | consider using `cv api extension.install`. 43 | '); 44 | } 45 | 46 | protected function execute(InputInterface $input, OutputInterface $output): int { 47 | // Refresh extensions if (a) ---refresh enabled or (b) there's a cache-miss. 48 | $refresh = $input->getOption('refresh') ? 'yes' : 'auto'; 49 | // $refresh = OptionalOption::parse(array('--refresh', '-r'), 'auto', 'yes'); 50 | while (TRUE) { 51 | if ($refresh === 'yes') { 52 | $output->writeln("Refreshing extension cache"); 53 | $result = VerboseApi::callApi3Success('Extension', 'refresh', array( 54 | 'local' => TRUE, 55 | 'remote' => FALSE, 56 | )); 57 | if (!empty($result['is_error'])) { 58 | return 1; 59 | } 60 | } 61 | 62 | [$foundKeys, $missingKeys] = $this->parseKeys($input, $output); 63 | if ($refresh == 'auto' && !empty($missingKeys)) { 64 | $output->writeln("Extension cache does not contain requested item(s)"); 65 | $refresh = 'yes'; 66 | } 67 | else { 68 | break; 69 | } 70 | } 71 | 72 | if ($missingKeys) { 73 | if ($input->getOption('ignore-missing')) { 74 | foreach ($missingKeys as $key) { 75 | $output->getErrorOutput()->writeln("Warning: Skipped unrecognized extension \"$key\""); 76 | } 77 | 78 | } 79 | else { 80 | foreach ($missingKeys as $key) { 81 | $output->getErrorOutput()->writeln("Error: Unrecognized extension \"$key\""); 82 | } 83 | return 1; 84 | } 85 | } 86 | 87 | foreach ($foundKeys as $key) { 88 | $output->writeln("Enabling extension \"$key\""); 89 | } 90 | 91 | $result = VerboseApi::callApi3Success('Extension', 'install', array( 92 | 'keys' => $foundKeys, 93 | )); 94 | return empty($result['is_error']) ? 0 : 1; 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /src/Command/ExtensionUninstallCommand.php: -------------------------------------------------------------------------------- 1 | setName('ext:uninstall') 24 | ->setAliases(array()) 25 | ->setDescription('Uninstall an extension and purge its data') 26 | ->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")') 27 | ->setHelp('Uninstall an extension and purge its data. 28 | 29 | Examples: 30 | cv ext:uninstall org.example.foobar 31 | cv ext:uninstall foobar 32 | 33 | Note: 34 | Beginning circa CiviCRM v4.2+, it has been recommended that extensions 35 | include a unique long name ("org.example.foobar") and a unique short 36 | name ("foobar"). However, short names are not strongly guaranteed. 37 | 38 | This subcommand does not output parseable data. For parseable output, 39 | consider using `cv api extension.uninstall`. 40 | '); 41 | } 42 | 43 | protected function execute(InputInterface $input, OutputInterface $output): int { 44 | [$foundKeys, $missingKeys] = $this->parseKeys($input, $output); 45 | 46 | // Uninstall what's recognized or what looks like an ext key. 47 | $uninstallKeys = array_merge($foundKeys, preg_grep('/\./', $missingKeys)); 48 | $missingKeys = preg_grep('/\./', $missingKeys, PREG_GREP_INVERT); 49 | 50 | foreach ($missingKeys as $key) { 51 | $output->writeln("Ignoring unrecognized extension \"$key\""); 52 | } 53 | foreach ($uninstallKeys as $key) { 54 | $output->writeln("Uninstalling extension \"$key\""); 55 | } 56 | 57 | $result = VerboseApi::callApi3Success('Extension', 'disable', array( 58 | 'keys' => $uninstallKeys, 59 | )); 60 | if (!empty($result['is_error'])) { 61 | return 1; 62 | } 63 | 64 | $result = VerboseApi::callApi3Success('Extension', 'uninstall', array( 65 | 'keys' => $uninstallKeys, 66 | )); 67 | return empty($result['is_error']) ? 0 : 1; 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /src/Command/ExtensionUpgradeDbCommand.php: -------------------------------------------------------------------------------- 1 | setName('ext:upgrade-db') 23 | ->setAliases(array()) 24 | ->setDescription('Apply DB upgrades for any extensions (DEPRECATED)') 25 | ->setHelp('Apply DB upgrades for any extensions 26 | 27 | Examples: 28 | cv ext:upgrade-db 29 | 30 | Note: 31 | This subcommand does not output parseable data. For parseable output, 32 | consider using `cv api extension.upgrade`. 33 | 34 | Deprecation: 35 | This command is now deprecated. Use "cv upgrade:db" to perform upgrades 36 | for core and/or extensions. 37 | '); 38 | } 39 | 40 | protected function initialize(InputInterface $input, OutputInterface $output) { 41 | $output->writeln("WARNING: \"ext:upgrade-db\" is deprecated. Use the main \"updb\" command instead."); 42 | parent::initialize($input, $output); 43 | } 44 | 45 | protected function execute(InputInterface $input, OutputInterface $output): int { 46 | $output->writeln("Applying database upgrades from extensions"); 47 | $result = VerboseApi::callApi3Success('Extension', 'upgrade', array()); 48 | if (!empty($result['is_error'])) { 49 | return 1; 50 | } 51 | return 0; 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/Command/FillCommand.php: -------------------------------------------------------------------------------- 1 | setName('vars:fill') 23 | ->setDescription('Generate a configuration file for any missing site data') 24 | ->addOption('file', NULL, InputOption::VALUE_REQUIRED, 'Read existing configuration from a file'); 25 | } 26 | 27 | public function __construct($name = NULL) { 28 | parent::__construct($name); 29 | $this->defaults = array( 30 | 'ADMIN_EMAIL' => 'admin@example.com', 31 | 'ADMIN_PASS' => 't0ps3cr3t', 32 | 'ADMIN_USER' => 'admin', 33 | 'CIVI_CORE' => '', 34 | 'CIVI_DB_DSN' => 'mysql://dbUser:dbPass@dbHost/dbName?new_link=true', 35 | 'CIVI_FILES' => '', 36 | 'CIVI_SETTINGS' => '', 37 | 'CIVI_SITE_KEY' => '', 38 | 'CIVI_TEMPLATEC' => '', 39 | 'CIVI_UF' => '', 40 | 'CIVI_URL' => '', 41 | 'CIVI_VERSION' => '', 42 | 'CMS_DB_DSN' => 'mysql://dbUser:dbPass@dbHost/dbName?new_link=true', 43 | 'CMS_ROOT' => '', 44 | 'CMS_TITLE' => 'Untitled installation', 45 | 'CMS_URL' => '', 46 | 'CMS_VERSION' => '', 47 | 'DEMO_EMAIL' => 'demo@example.com', 48 | 'DEMO_PASS' => 't0ps3cr3t', 49 | 'DEMO_USER' => 'demo', 50 | 'IS_INSTALLED' => '1', 51 | 'SITE_TOKEN' => md5(openssl_random_pseudo_bytes(256)), 52 | 'SITE_TYPE' => '', 53 | 'TEST_DB_DSN' => 'mysql://dbUser:dbPass@dbHost/dbName?new_link=true', 54 | ); 55 | } 56 | 57 | protected function execute(InputInterface $input, OutputInterface $output): int { 58 | if (!$input->getOption('file')) { 59 | $reader = new SiteConfigReader(CIVICRM_SETTINGS_PATH); 60 | $liveData = $reader->compile(array('buildkit', 'home', 'active')); 61 | } 62 | else { 63 | $file = $input->getOption('file'); 64 | if (strpos($file, '://') !== FALSE) { 65 | throw new \RuntimeException("Failed to extract current configuration."); 66 | } 67 | if ($file === '/dev/stdin') { 68 | $file = 'php://stdin'; 69 | } 70 | $liveData = json_decode(file_get_contents($file), 1); 71 | } 72 | 73 | if ($liveData === NULL) { 74 | throw new \RuntimeException("Failed to extract current configuration."); 75 | } 76 | 77 | $siteConfig = array(); 78 | foreach ($this->defaults as $field => $value) { 79 | if (!isset($liveData[$field])) { 80 | $siteConfig[$field] = $value; 81 | } 82 | } 83 | 84 | $output->writeln(sprintf("Site: %s", CIVICRM_SETTINGS_PATH)); 85 | if (empty($siteConfig)) { 86 | $output->writeln("No extra fields are required."); 87 | } 88 | else { 89 | $output->writeln(sprintf("These fields were missing. Setting defaults:")); 90 | $output->writeln(Encoder::encode($siteConfig, 'json-pretty')); 91 | Config::update(function ($config) use ($siteConfig, $output) { 92 | if (isset($config['sites'][CIVICRM_SETTINGS_PATH])) { 93 | $config['sites'][CIVICRM_SETTINGS_PATH] = array_merge($siteConfig, $config['sites'][CIVICRM_SETTINGS_PATH]); 94 | } 95 | else { 96 | $config['sites'][CIVICRM_SETTINGS_PATH] = $siteConfig; 97 | } 98 | ksort($config['sites'][CIVICRM_SETTINGS_PATH]); 99 | return $config; 100 | }); 101 | $output->writeln(sprintf("Please edit %s", Config::getFileName())); 102 | } 103 | return 0; 104 | } 105 | 106 | } 107 | -------------------------------------------------------------------------------- /src/Command/FlushCommand.php: -------------------------------------------------------------------------------- 1 | setName('flush') 14 | ->setAliases(array()) 15 | ->addOption('triggers', 'T', InputOption::VALUE_NONE, 'Rebuild triggers') 16 | ->setDescription('Flush system caches') 17 | ->setHelp(' 18 | Flush system caches 19 | '); 20 | } 21 | 22 | protected function initialize(InputInterface $input, OutputInterface $output) { 23 | // The main reason we have this as separate command -- so we can ignore 24 | // stale class-references that might be retained by the container cache. 25 | define('CIVICRM_CONTAINER_CACHE', 'never'); 26 | 27 | // Now we can let the parent proceed with bootstrap... 28 | parent::initialize($input, $output); 29 | } 30 | 31 | protected function execute(InputInterface $input, OutputInterface $output): int { 32 | $params = array(); 33 | if ($input->getOption('triggers')) { 34 | $params['triggers'] = TRUE; 35 | } 36 | 37 | $output->writeln("Flushing system caches"); 38 | $result = VerboseApi::callApi3Success('System', 'flush', $params); 39 | return empty($result['is_error']) ? 0 : 1; 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/Command/PipeCommand.php: -------------------------------------------------------------------------------- 1 | setName('pipe') 20 | ->setDescription('Start a Civi::pipe session (JSON-RPC 2.0)') 21 | ->setHelp('Start a Civi::pipe session (JSON-RPC 2.0) 22 | 23 | The Civi::pipe protocol provides a line-oriented session for executing multiple requests in a single CiviCRM instance. 24 | 25 | Callers may request *connection flags*, such as: 26 | 27 | * v: Show version 28 | * l: Show login support 29 | * t: Enable trusted mode 30 | * u: Enable untrusted mode 31 | 32 | Examples: 33 | 34 | $ cv pipe 35 | {"Civi::pipe":{"v":"5.47.alpha1","t":"trusted","l":["nologin"]}} 36 | 37 | $ cv pipe uv 38 | {"Civi::pipe":{"u":"untrusted","v":"5.47.alpha1"}} 39 | 40 | See also: https://docs.civicrm.org/dev/en/latest/framework/pipe 41 | '); 42 | $this->addArgument('connection-flags', InputArgument::OPTIONAL, 'List of connection-flags (Default: Determined by civicrm-core)', NULL); 43 | // Tempting to add separate CLI flags that map to 'connection-flags', but this seems easier given that: 44 | // 1. The existing flags have different meanings (eg (v)erbose vs (v)ersion). 45 | // 2. The connection-flags are owned by civicrm-core.git. Don't want to update this whenever there's a new one. 46 | } 47 | 48 | protected function execute(InputInterface $input, OutputInterface $output): int { 49 | if (!is_callable(['Civi', 'pipe'])) { 50 | fwrite(STDERR, "This version of CiviCRM does not include Civi::pipe() support.\n"); 51 | return 1; 52 | } 53 | if ($flags = $input->getArgument('connection-flags')) { 54 | \Civi::pipe($flags); 55 | } 56 | else { 57 | \Civi::pipe(); 58 | } 59 | return 0; 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/Command/QueueNextCommand.php: -------------------------------------------------------------------------------- 1 | setName('console-queue:run-next') 16 | ->setDescription('(INTERNAL) Run the next task in queue') 17 | ->setHidden(TRUE) 18 | ->addOption('steal', NULL, InputOption::VALUE_NONE) 19 | ->addOption('skip', NULL, InputOption::VALUE_NONE) 20 | ->addOption('out', NULL, InputOption::VALUE_REQUIRED, 'Store outcome in a JSON file') 21 | ->addOption('queue', NULL, InputOption::VALUE_REQUIRED, 'Queue name') 22 | ->addOption('queue-spec', NULL, InputOption::VALUE_REQUIRED, 'Queue specification (Base64-JSON)'); 23 | } 24 | 25 | protected function execute(InputInterface $input, OutputInterface $output): int { 26 | $outFile = $input->getOption('out'); 27 | if ($outFile && file_exists($outFile)) { 28 | $this->writeFile($outFile, ''); 29 | } 30 | 31 | $queue = $this->getQueue($input); 32 | 33 | $runner = new CRM_Queue_Runner([ 34 | 'queue' => $queue, 35 | ]); 36 | if ($input->getOption('skip')) { 37 | $result = $runner->skipNext($input->getOption('steal')); 38 | } 39 | else { 40 | $result = $runner->runNext($input->getOption('steal')); 41 | } 42 | 43 | if ($outFile) { 44 | $this->writeFile($outFile, json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); 45 | } 46 | 47 | if (!empty($result['exception'])) { 48 | throw $result['exception']; 49 | } 50 | return empty($result['is_error']) ? 0 : 1; 51 | } 52 | 53 | private function writeFile(string $outFile, string $data): void { 54 | $parent = dirname($outFile); 55 | if (!is_dir($parent)) { 56 | if (!mkdir($parent, 0777, TRUE)) { 57 | throw new \RuntimeException("Failed to create directory $parent"); 58 | } 59 | } 60 | $result = file_put_contents($outFile, $data); 61 | if ($result === FALSE) { 62 | throw new \RuntimeException("Failed to write to $outFile"); 63 | } 64 | } 65 | 66 | /** 67 | * @param \Symfony\Component\Console\Input\InputInterface $input 68 | * @return \CRM_Queue_Queue 69 | */ 70 | private function getQueue(InputInterface $input): \CRM_Queue_Queue { 71 | // cv is evergreen and may be used on old versions, so we accept either --queue-spec (old style, non-persistent queues) 72 | // or --queue (new style, persistent queues). 73 | if ($input->getOption('queue-spec')) { 74 | // For old-fashioned systems which lack support for persistent queue-definitions. 75 | $spec = json_decode(base64_decode($input->getOption('queue-spec')), TRUE); 76 | if (empty($spec)) { 77 | throw new \InvalidArgumentException('Queue spec is empty or malformed'); 78 | } 79 | $queue = \CRM_Queue_Service::singleton()->create($spec); 80 | } 81 | elseif ($input->getOption('queue')) { 82 | $queue = Civi::queue($input->getOption('queue')); 83 | } 84 | else { 85 | throw new \LogicException("Must specify either --queue or --queue-spec"); 86 | } 87 | return $queue; 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /src/Command/ScriptCommand.php: -------------------------------------------------------------------------------- 1 | setName('php:script') 14 | ->setAliases(array('scr')) 15 | ->setDescription('Execute a PHP script') 16 | ->addArgument('script', InputArgument::REQUIRED) 17 | ->addArgument('scriptArguments', InputArgument::IS_ARRAY, 'Optional arguments to pass to the script as $argv'); 18 | } 19 | 20 | public function getBootOptions(): array { 21 | return ['auto' => FALSE] + parent::getBootOptions(); 22 | } 23 | 24 | protected function execute(InputInterface $input, OutputInterface $output): int { 25 | $fs = new Filesystem(); 26 | $origScript = $fs->toAbsolutePath($input->getArgument('script')); 27 | $scriptArguments = $input->getArgument('scriptArguments'); 28 | 29 | $origCwd = getcwd(); 30 | $this->boot($input, $output); 31 | $postCwd = getcwd(); 32 | 33 | // Normal operation: Use the script path provided at input. 34 | if (file_exists($origScript)) { 35 | chdir($origCwd); 36 | $this->runScript($output, $origScript, $scriptArguments); 37 | return 0; 38 | } 39 | 40 | // Backward compat: Try script relative the post-boot CWD. 41 | $postScript = $fs->toAbsolutePath($input->getArgument('script')); 42 | if (file_exists($postScript)) { 43 | $output->getErrorOutput()->writeln("WARNING: Loaded script relative to CMS root -- which is deprecated. Script path should be (a) absolute or (b) relative to CWD."); 44 | chdir($postCwd); 45 | $this->runScript($output, $postScript, $scriptArguments); 46 | return 0; 47 | } 48 | 49 | $output->getErrorOutput()->writeln("Failed to locate script: " . $input->getArgument('script') . ""); 50 | return 1; 51 | } 52 | 53 | /** 54 | * @param \Symfony\Component\Console\Output\OutputInterface $output 55 | * @param string $script 56 | * @param array $scriptArguments 57 | */ 58 | protected function runScript(OutputInterface $output, string $script, array $scriptArguments = []) { 59 | $output->writeln("[ScriptCommand] Start \"$script\"", OutputInterface::VERBOSITY_DEBUG); 60 | // This puts the script arguments in the same variable scope as the script 61 | // so scripts can access arguments using $argv $argc 62 | $argv = $scriptArguments; 63 | array_unshift($argv, $script); 64 | $argc = count($argv); 65 | // This prevents the script stomping on any variables it shouldn't - like $output 66 | $run = function () use ($argv, $argc, $script) { 67 | require $script; 68 | }; 69 | $run(); 70 | $output->writeln("[ScriptCommand] Finish \"$script\"", OutputInterface::VERBOSITY_DEBUG); 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /src/Command/ShowCommand.php: -------------------------------------------------------------------------------- 1 | setName('vars:show') 16 | ->setDescription('Show the configuration of the local CiviCRM installation') 17 | ->configureOutputOptions(); 18 | } 19 | 20 | protected function execute(InputInterface $input, OutputInterface $output): int { 21 | $reader = new SiteConfigReader(CIVICRM_SETTINGS_PATH); 22 | $data = $reader->compile(array('buildkit', 'home', 'active')); 23 | $this->sendResult($input, $output, $data); 24 | return 0; 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/Command/UpgradeCommand.php: -------------------------------------------------------------------------------- 1 | setName('upgrade') 16 | ->setDescription('Download CiviCRM and upgrade the site') 17 | ->addOption('in', NULL, InputOption::VALUE_REQUIRED, 'Input format (args,json)', 'args') 18 | ->configureOutputOptions() 19 | ->addOption('stability', 's', InputOption::VALUE_REQUIRED, 'Specify the stability of the version to get (nightly, rc, stable)', 'stable') 20 | ->setHelp('Download CiviCRM, extract in place, and upgrade the site, notifying civicrm.org 21 | Examples: 22 | cv upgrade --stability=rc 23 | '); 24 | // parent::configureBootOptions(); 25 | } 26 | 27 | protected function execute(InputInterface $input, OutputInterface $output): int { 28 | 29 | throw new \RuntimeException("FIXME: Calls to run() should be escaped, e.g. with Process::sprintf()"); 30 | 31 | $exitCode = 0; 32 | $stage = 'Start'; 33 | $result = array(); 34 | $reportName = \Civi\Cv\Util\Rand::createName(); 35 | try { 36 | $stability = $input->getOption('stability'); 37 | $stage = 'Lookup'; 38 | $dl = \Civi\Cv\Util\Cv::run("upgrade:get --stability=$stability"); 39 | $result['dl-data'] = $dl; 40 | if (!empty($dl['error'])) { 41 | throw new \RuntimeException($dl['error'], 1); 42 | } 43 | $stage = 'Start report'; 44 | $startReport = \Civi\Cv\Util\Cv::run("upgrade:report --started --revision={$dl['rev']} --name=$reportName"); 45 | $stage = 'Download and extract'; 46 | $extract = \Civi\Cv\Util\Cv::run("upgrade:dl --url={$dl['url']}"); 47 | $result['extract-data'] = $extract; 48 | $stage = 'Download report'; 49 | $dlReport = \Civi\Cv\Util\Cv::run("upgrade:report --downloaded --download-url {$dl['url']} --extracted --name $reportName"); 50 | $stage = 'Database upgrade'; 51 | $db = \Civi\Cv\Util\Cv::run("upgrade:db"); 52 | $result['db-upgrade'] = $db; 53 | $messageFile = sys_get_temp_dir() . "/upgrademessages-$reportName.html"; 54 | file_put_contents($messageFile, $db['message']); 55 | $stage = 'Upgrade report'; 56 | $finishReport = \Civi\Cv\Util\Cv::run("upgrade:report --upgraded --upgrade-messages $messageFile --name $reportName"); 57 | 58 | // clean up 59 | $stage = 'Cleanup'; 60 | unlink($messageFile); 61 | } 62 | catch (\RuntimeException $e) { 63 | $exitCode = 1; 64 | $result['error-stage'] = $stage; 65 | $result['error-message'] = $e->getMessage(); 66 | $problem = "$stage problem: {$result['error_message']}"; 67 | $result['fail-report'] = \Civi\Cv\Util\Cv::run("upgrade:report --failed --problem-message '$problem' --name $reportName"); 68 | } 69 | 70 | $this->sendResult($input, $output, $result); 71 | return $exitCode; 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /src/Command/UpgradeGetCommand.php: -------------------------------------------------------------------------------- 1 | setName('upgrade:get') 24 | ->setDescription('Find out what file you should use to upgrade') 25 | ->configureOutputOptions() 26 | ->addOption('stability', 's', InputOption::VALUE_REQUIRED, 'Specify the stability of the version to get (nightly, rc, stable)', 'stable') 27 | ->addOption('cms', 'c', InputOption::VALUE_REQUIRED, 'Specify the CMS to get (Backdrop, Drupal, Drupal6, Joomla, WordPress) instead of the current site') 28 | ->setHelp('Find out what file you should use to upgrade 29 | 30 | Examples: 31 | cv upgrade:get --stability=rc 32 | 33 | Returns a JSON object with the properties: 34 | rev a unique ID corresponding to the commits that are included 35 | path the path to download a tarball/zipfile 36 | git the corresponding commits of the civicrm repos 37 | vars the site variables from cv vars:show 38 | error only appears if there is an error 39 | '); 40 | } 41 | 42 | public function getBootOptions(): array { 43 | return ['auto' => FALSE] + parent::getBootOptions(); 44 | } 45 | 46 | protected function execute(InputInterface $input, OutputInterface $output): int { 47 | $result = array(); 48 | $exitCode = 0; 49 | $stability = $input->getOption('stability'); 50 | $cms = $input->getOption('cms'); 51 | if (empty($cms)) { 52 | $this->boot($input, $output); 53 | if (defined('CIVICRM_UF')) { 54 | $cms = CIVICRM_UF; 55 | } 56 | // REMOVE: 57 | $result['vars'] = $GLOBALS['_CV']; 58 | } 59 | if (empty($cms)) { 60 | throw new \RuntimeException("Cannot determine download URL without CMS"); 61 | } 62 | 63 | $url = self::DEFAULT_CHECK_URL . "?stability=" . urlencode($stability); 64 | $lookup = json_decode(file_get_contents($url), TRUE); 65 | 66 | if (empty($lookup)) { 67 | $result = array( 68 | 'error' => "Version not found at $url", 69 | ); 70 | $exitCode = 1; 71 | } 72 | else { 73 | if (array_key_exists('rev', $lookup)) { 74 | $result['rev'] = $lookup['rev']; 75 | } 76 | if (array_key_exists('version', $lookup)) { 77 | $result['version'] = $lookup['version']; 78 | } 79 | if (array_key_exists('git', $lookup)) { 80 | $result['git'] = $lookup['git']; 81 | } 82 | if (!empty($lookup['tar'][$cms])) { 83 | $result['url'] = $lookup['tar'][$cms]; 84 | } 85 | } 86 | 87 | $this->sendResult($input, $output, $result); 88 | return $exitCode; 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /src/Encoder.php: -------------------------------------------------------------------------------- 1 | $v) { 90 | $buf .= (sprintf("%s=%s\n", $k, escapeshellarg($v))); 91 | } 92 | return $buf; 93 | } 94 | else { 95 | return gettype($data); 96 | } 97 | 98 | case 'civicrm.settings.php': 99 | // SpecialFormat: Blurbs for civicrm.settings.php. Used by `setting:get` command. 100 | $buf = ''; 101 | foreach ($data as $row) { 102 | if (!isset($row['scope']) || !isset($row['key']) || !isset($row['value'])) { 103 | throw new \RuntimeException("Cannot format result for civicrm.settings.php. Must have columns: scope,key,value"); 104 | } 105 | $scope = strpos($row['scope'], 'contact') !== FALSE ? 'contact' : 'domain'; 106 | $buf .= sprintf("\$civicrm_setting[%s][%s] = %s;\n", 107 | var_export($scope, TRUE), 108 | var_export($row['key'], TRUE), 109 | var_export($row['value'], TRUE) 110 | ); 111 | } 112 | return $buf; 113 | 114 | default: 115 | throw new \RuntimeException('Unknown output format'); 116 | } 117 | } 118 | 119 | private static function preferArray($data) { 120 | if (is_object($data)) { 121 | if ($data instanceof \JsonSerializable || $data instanceof \stdClass) { 122 | $data = json_decode(json_encode($data), TRUE); 123 | } 124 | } 125 | return $data; 126 | } 127 | 128 | } 129 | -------------------------------------------------------------------------------- /src/Exception/ProcessErrorException.php: -------------------------------------------------------------------------------- 1 | process = $process; 12 | if (empty($message)) { 13 | $message = $this->createReport($process); 14 | } 15 | parent::__construct($message, $code, $previous); 16 | } 17 | 18 | /** 19 | * @param \Symfony\Component\Process\Process $process 20 | */ 21 | public function setProcess($process) { 22 | $this->process = $process; 23 | } 24 | 25 | /** 26 | * @return \Symfony\Component\Process\Process 27 | */ 28 | public function getProcess() { 29 | return $this->process; 30 | } 31 | 32 | public function createReport($process) { 33 | return "Process failed: 34 | [[ COMMAND: {$process->getCommandLine()} ]] 35 | [[ CWD: {$process->getWorkingDirectory()} ]] 36 | [[ EXIT CODE: {$process->getExitCode()} ]] 37 | [[ STDOUT ]] 38 | {$process->getOutput()} 39 | [[ STDERR ]] 40 | {$process->getErrorOutput()} 41 | "; 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/Exception/QueueTaskException.php: -------------------------------------------------------------------------------- 1 | applyOption($params, $state, $arg); 20 | $state = '_TOP_'; 21 | } 22 | // Ex: 'foo=bar', 'fo.oo=bar', 'fo:oo=bar' 23 | elseif (preg_match('/^([a-zA-Z0-9_:\.]+)=(.*)/', $arg, $matches)) { 24 | [, $key, $value] = $matches; 25 | $params[$key] = $this->parseValueExpr($value); 26 | } 27 | // Ex: '+w', '+where' 28 | elseif (preg_match('/^\+([a-zA-Z0-9_]+)$/', $arg, $matches)) { 29 | $state = $matches[1]; 30 | } 31 | // Ex: '+l=2', '+l:2' 32 | elseif (preg_match('/^\+([a-zA-Z0-9_]+)[:=](.*)/', $arg, $matches)) { 33 | [, $key, $expr] = $matches; 34 | $this->applyOption($params, $key, $expr); 35 | } 36 | // Ex: '{"foo": "bar"}' 37 | elseif (preg_match('/^\{.*\}$/', $arg)) { 38 | $params = array_merge($params, $this->parseJsonNoisily($arg)); 39 | } 40 | else { 41 | throw new \RuntimeException("Unrecognized option format: $arg"); 42 | } 43 | } 44 | return $params; 45 | } 46 | 47 | protected static function mergeInto(&$params, $key, $values) { 48 | if (!isset($params[$key])) { 49 | $params[$key] = []; 50 | } 51 | $params[$key] = array_merge($params[$key], $values); 52 | } 53 | 54 | protected static function appendInto(&$params, $key, $values) { 55 | if (!isset($params[$key])) { 56 | $params[$key] = []; 57 | } 58 | $params[$key][] = $values; 59 | } 60 | 61 | /** 62 | * @param string $arg 63 | * @return mixed 64 | */ 65 | protected function parseJsonNoisily($arg) { 66 | $values = json_decode($arg, 1); 67 | if ($values === NULL) { 68 | throw new \RuntimeException("Failed to parse JSON: $values"); 69 | } 70 | return $values; 71 | } 72 | 73 | /** 74 | * @param $expr 75 | * @return mixed 76 | */ 77 | protected function parseValueExpr($expr) { 78 | if ($expr !== '' && strpos('{["\'', $expr[0]) !== FALSE) { 79 | return $this->parseJsonNoisily($expr); 80 | } 81 | else { 82 | return $expr; 83 | } 84 | } 85 | 86 | /** 87 | * @param array $params 88 | * @param string $type 89 | * The name of the plus parameter. 90 | * Ex: "+s foo" ==> "s" 91 | * Ex: "+where foo=bar" ==> "where" 92 | * @param string $expr 93 | * Ex: "+s foo" ==> "foo" 94 | * Ex: "+where foo=bar" ==> "foo=bar" 95 | * 96 | * @return mixed 97 | */ 98 | abstract protected function applyOption(array &$params, string $type, string $expr): void; 99 | 100 | public function parseAssignment($expr) { 101 | if (preg_match('/^([a-zA-Z0-9_:\.]+)\s*=\s*(.*)$/', $expr, $matches)) { 102 | return [$matches[1] => $this->parseValueExpr($matches[2])]; 103 | } 104 | else { 105 | throw new \RuntimeException("Error parsing \"value\": $expr"); 106 | } 107 | } 108 | 109 | } 110 | -------------------------------------------------------------------------------- /src/Util/Api4ArgParser.php: -------------------------------------------------------------------------------- 1 | operators = implode('|', ArrayUtil::map(\Civi\Api4\Utils\CoreUtil::getOperators(), function($op) { 16 | return preg_quote($op, '/'); 17 | })); 18 | } 19 | else { 20 | $this->operators = '\<=|\>=|=|!=|\<|\>|IS NULL|IS NOT NULL|IS EMPTY|IS NOT EMPTY|LIKE|NOT LIKE|IN|NOT IN|CONTAINS|NOT CONTAINS|REGEXP|NOT REGEXP|REGEXP BINARY|NOT REGEXP BINARY'; 21 | } 22 | } 23 | 24 | /** 25 | * @param array $params 26 | * @param string $type 27 | * @param string $expr 28 | * 29 | * @return mixed 30 | */ 31 | protected function applyOption(array &$params, string $type, string $expr): void { 32 | $aliases = [ 33 | 's' => 'select', 34 | 'w' => 'where', 35 | 'o' => 'orderBy', 36 | 'l' => 'limit', 37 | 'v' => 'values', 38 | 'value' => 'values', 39 | ]; 40 | $type = $aliases[$type] ?? $type; 41 | 42 | switch ($type) { 43 | case 'select': 44 | self::mergeInto($params, $type, array_map([ 45 | $this, 46 | 'parseValueExpr', 47 | ], preg_split('/[, ]/', $expr))); 48 | break; 49 | 50 | case 'where': 51 | self::appendInto($params, $type, $this->parseWhere($expr)); 52 | break; 53 | 54 | case 'orderBy': 55 | $keyOrderPairs = explode(',', $expr); 56 | foreach ($keyOrderPairs as $keyOrderPair) { 57 | $keyOrderPair = explode(' ', trim($keyOrderPair)); 58 | $sortKey = $keyOrderPair[0]; 59 | $sortOrder = isset($keyOrderPair[1]) ? strtoupper($keyOrderPair[1]) : 'ASC'; 60 | $params[$type][$sortKey] = $sortOrder; 61 | } 62 | break; 63 | 64 | case 'limit': 65 | if (strpos($expr, '@') !== FALSE) { 66 | [$limit, $offset] = explode('@', $expr); 67 | $params['limit'] = (int) $limit; 68 | $params['offset'] = (int) $offset; 69 | } 70 | else { 71 | $params['limit'] = (int) $expr; 72 | } 73 | break; 74 | 75 | case 'values': 76 | self::mergeInto($params, $type, $this->parseAssignment($expr)); 77 | break; 78 | 79 | default: 80 | throw new \RuntimeException("Unrecognized option: +$type"); 81 | } 82 | } 83 | 84 | public function parseWhere($expr) { 85 | if (preg_match('/^([a-zA-Z0-9_:\.]+)\s*(' . $this->operators . ')\s*(.*)$/i', $expr, $matches)) { 86 | if (!empty($matches[3])) { 87 | return [$matches[1], strtoupper(trim($matches[2])), $this->parseValueExpr(trim($matches[3]))]; 88 | } 89 | else { 90 | return [$matches[1], strtoupper($matches[2])]; 91 | } 92 | } 93 | else { 94 | throw new \RuntimeException("Error parsing \"where\": $expr"); 95 | } 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /src/Util/CliEditor.php: -------------------------------------------------------------------------------- 1 | editFile($tmpFile, $attempts)) { 28 | unlink($tmpFile); 29 | return NULL; 30 | } 31 | 32 | $buffer = file_get_contents($tmpFile); 33 | unlink($tmpFile); 34 | return $buffer; 35 | } 36 | 37 | /** 38 | * Open the editor with a given file. 39 | * 40 | * @param string $file 41 | * @param int $maxAttempts 42 | * @return bool 43 | * TRUE on success. 44 | */ 45 | public function editFile($file, $maxAttempts = 2) { 46 | $attempt = 0; 47 | do { 48 | $attempt++; 49 | $cmd = $this->pick(); 50 | if (!$cmd) { 51 | return FALSE; 52 | } 53 | passthru("$cmd " . escapeshellarg($file), $return); 54 | if (!$return !== 0) { 55 | return FALSE; 56 | } 57 | 58 | if ($this->validator === NULL) { 59 | $isValid = TRUE; 60 | } 61 | else { 62 | list ($isValid, $message) = call_user_func($this->validator, $file); 63 | if (!$isValid && $attempt >= $maxAttempts) { 64 | return FALSE; 65 | } 66 | if (!$isValid && $message) { 67 | file_put_contents($file, $message . file_get_contents($file)); 68 | } 69 | } 70 | 71 | } while (!$isValid); 72 | return TRUE; 73 | } 74 | 75 | /** 76 | * Determine the name of the editor. 77 | * 78 | * @return string|NULL 79 | */ 80 | public function pick() { 81 | if (getenv('VISUAL') && $this->findCommand(getenv('VISUAL'))) { 82 | return getenv('VISUAL'); 83 | } 84 | elseif (getenv('EDITOR') && $this->findCommand(getenv('EDITOR'))) { 85 | return getenv('EDITOR'); 86 | } 87 | elseif ($this->findCommand('editor')) { 88 | return 'editor'; 89 | } 90 | elseif ($this->findCommand('vi')) { 91 | return 'vi'; 92 | } 93 | else { 94 | return NULL; 95 | } 96 | } 97 | 98 | protected function findCommand($name) { 99 | return Process::findCommand($name); 100 | } 101 | 102 | /** 103 | * @return callable|NULL 104 | */ 105 | public function getValidator() { 106 | return $this->validator; 107 | } 108 | 109 | /** 110 | * @param callable|NULL $validator 111 | */ 112 | public function setValidator($validator) { 113 | $this->validator = $validator; 114 | } 115 | 116 | } 117 | -------------------------------------------------------------------------------- /src/Util/ConsoleQueueRunner.php: -------------------------------------------------------------------------------- 1 | io = $io; 45 | $this->queue = $queue; 46 | $this->dryRun = $dryRun; 47 | $this->step = (bool) $step; 48 | } 49 | 50 | /** 51 | * @throws \Exception 52 | */ 53 | public function runAll() { 54 | /** @var \Symfony\Component\Console\Style\SymfonyStyle $io */ 55 | $io = $this->io; 56 | 57 | $taskCtx = new \CRM_Queue_TaskContext(); 58 | $taskCtx->queue = $this->queue; 59 | // WISHLIST: Wrap $output 60 | $taskCtx->log = \Log::singleton('display'); 61 | // CRM_Core_Error::createDebugLogger() 62 | 63 | while ($this->queue->numberOfItems()) { 64 | // In case we're retrying a failed job. 65 | $item = $this->queue->stealItem(); 66 | $task = $item->data; 67 | 68 | if ($io->getVerbosity() === OutputInterface::VERBOSITY_NORMAL) { 69 | // Symfony progress bar would be prettier, but (when last checked) they didn't allow 70 | // resetting when the queue-length expands dynamically. 71 | $io->write("."); 72 | } 73 | elseif ($io->getVerbosity() === OutputInterface::VERBOSITY_VERBOSE) { 74 | $io->writeln(sprintf("%s", $task->title)); 75 | } 76 | elseif ($io->getVerbosity() > OutputInterface::VERBOSITY_VERBOSE) { 77 | $io->writeln(sprintf("%s (%s)", $task->title, self::formatTaskCallback($task))); 78 | } 79 | 80 | $action = 'y'; 81 | if ($this->step) { 82 | $action = $io->choice('Execute this step?', ['y' => 'yes', 's' => 'skip', 'a' => 'abort'], 'y'); 83 | } 84 | if ($action === 'a') { 85 | throw new \Exception('Aborted'); 86 | } 87 | 88 | if ($action === 'y' && !$this->dryRun) { 89 | try { 90 | $isOK = $task->run($taskCtx); 91 | if (!$isOK) { 92 | throw new QueueTaskException('Task returned false'); 93 | } 94 | } 95 | catch (\Throwable $e) { 96 | // WISHLIST: For interactive mode, perhaps allow retry/skip? 97 | $io->writeln(sprintf("Error executing task: %s", $task->title)); 98 | throw $e; 99 | } 100 | } 101 | 102 | $this->queue->deleteItem($item); 103 | } 104 | 105 | if ($io->getVerbosity() === OutputInterface::VERBOSITY_NORMAL) { 106 | $io->newLine(); 107 | } 108 | } 109 | 110 | protected static function formatTaskCallback(\CRM_Queue_Task $task) { 111 | $cb = implode('::', (array) $task->callback); 112 | $args = json_encode($task->arguments, JSON_UNESCAPED_SLASHES); 113 | return sprintf("%s(%s)", $cb, substr($args, 1, -1)); 114 | } 115 | 116 | } 117 | -------------------------------------------------------------------------------- /src/Util/DebugDispatcherTrait.php: -------------------------------------------------------------------------------- 1 | getListeners(); 23 | } 24 | elseif ($eventFilter[0] === '/') { 25 | $listenersByEvent = array(); 26 | foreach ($dispatcher->getListeners() as $e => $ls) { 27 | if (preg_match($eventFilter, $e)) { 28 | $listenersByEvent[$e] = $ls; 29 | } 30 | } 31 | } 32 | else { 33 | $listenersByEvent = array($eventFilter => $dispatcher->getListeners($eventFilter)); 34 | } 35 | 36 | $eventNames = array_keys($listenersByEvent); 37 | sort($eventNames); 38 | return $eventNames; 39 | } 40 | 41 | /** 42 | * @param \Symfony\Component\Console\Output\OutputInterface $output 43 | * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher 44 | * @param array $eventNames 45 | */ 46 | public function printEventListeners(OutputInterface $output, $dispatcher, $eventNames) { 47 | $fmt = class_exists('\Civi\Core\Event\EventPrinter') 48 | ? ['\Civi\Core\Event\EventPrinter', 'formatName'] : NULL; 49 | 50 | foreach ($eventNames as $event) { 51 | $rows = array(); 52 | $i = 0; 53 | foreach ($dispatcher->getListeners($event) as $listener) { 54 | $handled = FALSE; 55 | if ($fmt != NULL) { 56 | $rows[] = array('#' . ++$i, $fmt($listener)); 57 | $handled = TRUE; 58 | } 59 | elseif (is_array($listener)) { 60 | list ($a, $b) = $listener; 61 | if (is_object($a)) { 62 | $rows[] = array('#' . ++$i, get_class($a) . "->$b()"); 63 | $handled = TRUE; 64 | } 65 | elseif (is_string($a)) { 66 | $rows[] = array('#' . ++$i, "$a::$b()"); 67 | $handled = TRUE; 68 | } 69 | } 70 | elseif (is_string($listener)) { 71 | $handled = TRUE; 72 | $rows[] = array('#' . ++$i, $listener . '()'); 73 | } 74 | elseif ($listener instanceof \Civi\Core\Event\ServiceListener) { 75 | $handled = TRUE; 76 | $rows[] = ['#' . ++$i, (string) $listener]; 77 | } 78 | else { 79 | try { 80 | $f = new \ReflectionFunction($listener); 81 | $rows[] = array( 82 | '#' . ++$i, 83 | 'closure(' . $f->getFileName() . '@' . $f->getStartLine() . ')', 84 | ); 85 | $handled = TRUE; 86 | } 87 | catch (\ReflectionException $e) { 88 | } 89 | } 90 | 91 | if (!$handled) { 92 | $rows[] = array('#' . ++$i, "unidentified"); 93 | } 94 | } 95 | $output->writeln("[Event] $event"); 96 | $table = new Table($output); 97 | $table->setHeaders(array('Order', 'Callable')); 98 | $table->addRows($rows); 99 | $table->render(); 100 | $output->writeln(""); 101 | } 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /src/Util/ExtensionTrait.php: -------------------------------------------------------------------------------- 1 | addOption('dev', NULL, InputOption::VALUE_NONE, 'Include developmental extensions. (Equivalent to "--filter-status=* --filter-ready=*")') 16 | ->addOption('filter-ver', NULL, InputOption::VALUE_REQUIRED, 'Filter remote extensions by Civi compatibility (Ex: "4.7.15","4.6.20")', '{ver}') 17 | ->addOption('filter-uf', NULL, InputOption::VALUE_REQUIRED, 'Filter remote extensions by CMS compatibility (Ex: "Drupal", "WordPress")', '{uf}') 18 | ->addOption('filter-status', NULL, InputOption::VALUE_REQUIRED, 'Filter remote extensions by stability flag (Ex: "stable", "*")', 'stable') 19 | ->addOption('filter-ready', NULL, InputOption::VALUE_REQUIRED, 'Filter remote extensions based on reviewers\' approval (Ex: "ready", "*")', 'ready'); 20 | } 21 | 22 | /** 23 | * Create a URL for the extension feed (based on user options). 24 | * 25 | * @param \Symfony\Component\Console\Input\InputInterface $input 26 | * @return string|NULL 27 | */ 28 | public function parseRepoUrl(InputInterface $input) { 29 | if ($input->getOption('dev')) { 30 | $input->setOption('filter-status', '*'); 31 | $input->setOption('filter-ready', '*'); 32 | } 33 | $parts = array(); 34 | foreach (array('ver', 'uf', 'status', 'ready') as $key) { 35 | $value = $input->getOption("filter-" . $key); 36 | if ($value === '*') { 37 | $value = ''; 38 | } 39 | $parts[] = $key . '=' . $value; 40 | } 41 | return 'https://civicrm.org/extdir/' . implode('|', $parts); 42 | } 43 | 44 | /** 45 | * @param \Symfony\Component\Console\Input\InputInterface $input 46 | * @param Symfony\Component\Console\Output\OutputInterface $output 47 | * @return array 48 | * Array(0=>$keys, 1=>$errors). 49 | */ 50 | protected function parseKeys(InputInterface $input, OutputInterface $output) { 51 | $allKeys = \CRM_Extension_System::singleton()->getFullContainer()->getKeys(); 52 | $foundKeys = array(); 53 | $missingKeys = array(); 54 | $shortMap = NULL; 55 | 56 | foreach ($input->getArgument('key-or-name') as $keyOrName) { 57 | if (in_array($keyOrName, $allKeys)) { 58 | $foundKeys[] = $keyOrName; 59 | continue; 60 | } 61 | 62 | if ($shortMap === NULL) { 63 | $shortMap = $this->getShortMap(); 64 | } 65 | if (isset($shortMap[$keyOrName])) { 66 | $foundKeys = array_merge($foundKeys, $shortMap[$keyOrName]); 67 | continue; 68 | } 69 | 70 | $missingKeys[] = $keyOrName; 71 | } 72 | 73 | return array($foundKeys, $missingKeys); 74 | } 75 | 76 | /** 77 | * @return array 78 | * Array(string $shortName => string $longName). 79 | */ 80 | protected function getShortMap() { 81 | $map = array(); 82 | $mapper = \CRM_Extension_System::singleton()->getMapper(); 83 | $container = \CRM_Extension_System::singleton()->getFullContainer(); 84 | 85 | foreach ($container->getKeys() as $key) { 86 | $info = $mapper->keyToInfo($key); 87 | if ($info->file) { 88 | $map[$info->file][] = $key; 89 | } 90 | } 91 | return $map; 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /src/Util/HeadlessDownloader.php: -------------------------------------------------------------------------------- 1 | exists($outputDir) && !$force) { 25 | throw new \RuntimeException("Directory already exists: $outputDir"); 26 | } 27 | 28 | $tmpDir = $outputDir . ".tmp"; 29 | if ($fs->exists($tmpDir) && !$force) { 30 | throw new \RuntimeException("Directory already exists: $tmpDir"); 31 | } 32 | 33 | $zipFile = tempnam(sys_get_temp_dir(), 'extdl-') . '.zip'; 34 | if ($fs->exists($tmpDir) && $force) { 35 | $fs->remove($tmpDir); 36 | } 37 | $this->download($zipUrl, $zipFile); 38 | $extractedZipPath = $this->extractZip($zipFile, $extKey, $tmpDir); 39 | if ($fs->exists($outputDir) && $force) { 40 | $fs->remove($outputDir); 41 | } 42 | rename($extractedZipPath, $outputDir); 43 | rmdir($tmpDir); 44 | unlink($zipFile); 45 | } 46 | 47 | /** 48 | * Determine the name. 49 | * 50 | * @param \ZipArchive $zip 51 | * @return array 52 | */ 53 | public function findBaseDirs(ZipArchive $zip) { 54 | $cnt = $zip->numFiles; 55 | $basedirs = array(); 56 | 57 | for ($i = 0; $i < $cnt; $i++) { 58 | $filename = $zip->getNameIndex($i); 59 | // hypothetically, ./ or ../ would not be legit here 60 | if (preg_match('/^[^\/]+\/$/', $filename) && $filename != './' && $filename != '../') { 61 | $basedirs[] = rtrim($filename, '/'); 62 | } 63 | } 64 | 65 | return $basedirs; 66 | } 67 | 68 | public function guessBasedir(ZipArchive $zip, $expected) { 69 | $candidate = FALSE; 70 | $basedirs = $this->findBaseDirs($zip); 71 | if (in_array($expected, $basedirs)) { 72 | $candidate = $expected; 73 | } 74 | elseif (count($basedirs) == 1) { 75 | $candidate = array_shift($basedirs); 76 | } 77 | if ($candidate !== FALSE && preg_match('/^[a-zA-Z0-9]/', $candidate)) { 78 | return $candidate; 79 | } 80 | else { 81 | return FALSE; 82 | } 83 | } 84 | 85 | public function extractZip($zipFile, $key, $tmpDir) { 86 | $zip = new ZipArchive(); 87 | $res = $zip->open($zipFile); 88 | if ($res === TRUE) { 89 | $zipSubDir = $this->guessBasedir($zip, $key); 90 | if ($zipSubDir === FALSE) { 91 | throw new \Exception('Unable to extract the extension: bad directory structure'); 92 | } 93 | $extractedZipPath = $tmpDir . DIRECTORY_SEPARATOR . $zipSubDir; 94 | if (is_dir($extractedZipPath)) { 95 | throw new \Exception("$extractedZipPath already exists"); 96 | } 97 | if (!is_dir($tmpDir)) { 98 | mkdir($tmpDir, 0777, TRUE); 99 | } 100 | if (!$zip->extractTo($tmpDir)) { 101 | throw new \Exception("Unable to extract the extension to $tmpDir."); 102 | } 103 | $zip->close(); 104 | return $extractedZipPath; 105 | } 106 | else { 107 | throw new \Exception('Unable to extract the extension.'); 108 | } 109 | } 110 | 111 | public function download($url, $file) { 112 | $fp = fopen($file, 'w'); 113 | $ch = curl_init($url); 114 | curl_setopt($ch, CURLOPT_FILE, $fp); 115 | curl_setopt($ch, CURLOPT_FOLLOWLOCATION, TRUE); 116 | curl_exec($ch); 117 | curl_close($ch); 118 | fclose($fp); 119 | } 120 | 121 | } 122 | -------------------------------------------------------------------------------- /src/Util/OptionCallbackTrait.php: -------------------------------------------------------------------------------- 1 | addOption('foo', ...) 15 | * ->addOptionCallback('foo', function(InputInterface $input, OutputInterface $output, InputOption $option) { ... }) 16 | */ 17 | trait OptionCallbackTrait { 18 | 19 | /** 20 | * @return \Symfony\Component\Console\Input\InputDefinition 21 | * An InputDefinition instance 22 | * @see \Symfony\Component\Console\Command\Command::getDefinition() 23 | */ 24 | abstract public function getDefinition(); 25 | 26 | /** 27 | * @var array 28 | * Array([string $optionName, callable $callback]). 29 | */ 30 | private $optionCallbacks = []; 31 | 32 | /** 33 | * @param string $name 34 | * The name of the option to 35 | * @param callable $callback 36 | * Function(InputInterface $input, OutputInterface, $output, string $optionName) 37 | * @return $this 38 | */ 39 | public function addOptionCallback($name, $callback) { 40 | $this->optionCallbacks[] = [$name, $callback]; 41 | return $this; 42 | } 43 | 44 | /** 45 | * @param \Symfony\Component\Console\Input\InputInterface $input 46 | * @param \Symfony\Component\Console\Output\OutputInterface $output 47 | * @return $this 48 | */ 49 | protected function runOptionCallbacks(InputInterface $input, OutputInterface $output) { 50 | $defn = $this->getDefinition(); 51 | 52 | foreach ($this->optionCallbacks as $optionCallbackDefn) { 53 | list ($optionName, $callback) = $optionCallbackDefn; 54 | if ($defn->hasOption($optionName)) { 55 | $callback($input, $output, $defn->getOption($optionName)); 56 | } 57 | } 58 | 59 | return $this; 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/Util/PsrLogger.php: -------------------------------------------------------------------------------- 1 | internalLog = $internalLog; 17 | } 18 | 19 | public function log($level, $message, array $context = array()): void { 20 | $this->internalLog->log($level, $message, $context); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/Util/Rand.php: -------------------------------------------------------------------------------- 1 | prefixes = [ 13 | '[civicrm.root]/' => \Civi::paths()->getPath('[civicrm.root]/'), 14 | '[civicrm.packages]/' => \Civi::paths()->getPath('[civicrm.packages]/'), 15 | '[civicrm.files]/' => \Civi::paths()->getPath('[civicrm.files]/'), 16 | '[cms.root]/' => \Civi::paths()->getPath('[cms.root]/'), 17 | ]; 18 | } 19 | 20 | /** 21 | * @param string $path 22 | * Ex: '/var/www/sites/all/modules/civicrm/CRM/Foo.php 23 | * @return string 24 | * Ex: '[civicrm.root]/CRM/Foo.php'' 25 | */ 26 | public function filter(string $path): string { 27 | foreach ($this->prefixes as $prefix => $prefixPath) { 28 | if (\CRM_Utils_File::isChildPath($prefixPath, $path)) { 29 | return $prefix . mb_substr($path, mb_strlen($prefixPath)); 30 | } 31 | } 32 | return $path; 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/Util/SettingCodec.php: -------------------------------------------------------------------------------- 1 | writeln("Calling $entity $action API", OutputInterface::VERBOSITY_DEBUG); 27 | $result = \civicrm_api($entity, $action, $params); 28 | if (!empty($result['is_error']) || $output->isDebug()) { 29 | $data = array( 30 | 'entity' => $entity, 31 | 'action' => $action, 32 | 'params' => $params, 33 | 'result' => $result, 34 | ); 35 | if (!empty($result['is_error'])) { 36 | \Civi\Cv\Cv::errorOutput()->writeln("Error: API Call Failed: " 37 | . Encoder::encode($data, 'pretty')); 38 | } 39 | else { 40 | $output->writeln("API success" . Encoder::encode($data, 'pretty'), 41 | OutputInterface::VERBOSITY_DEBUG); 42 | } 43 | } 44 | return $result; 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /tests/CivilTestCase.php: -------------------------------------------------------------------------------- 1 | originalCwd = getcwd(); 24 | chdir($this->getExampleDir()); 25 | $this->cv = dirname(__DIR__) . '/bin/cv'; 26 | } 27 | 28 | public function tearDown(): void { 29 | chdir($this->originalCwd); 30 | } 31 | 32 | public function getExampleDir() { 33 | $dir = getenv('CV_TEST_BUILD'); 34 | if (empty($dir)) { 35 | throw new \RuntimeException('Environment variable CV_TEST_BUILD must point to a civicrm-cms build'); 36 | } 37 | return $dir; 38 | } 39 | 40 | /** 41 | * Create a helper for executing command-tests in our application. 42 | * 43 | * @param array $args must include key "command" 44 | * @return \Symfony\Component\Console\Tester\CommandTester 45 | */ 46 | public function createCommandTester($args) { 47 | if (!isset($args['command'])) { 48 | throw new \RuntimeException("Missing mandatory argument: command"); 49 | } 50 | $application = new Application(); 51 | $command = $application->find($args['command']); 52 | $commandTester = new CommandTester($command); 53 | $commandTester->execute($args); 54 | return $commandTester; 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /tests/Command/AngularHtmlListCommandTest.php: -------------------------------------------------------------------------------- 1 | cv('ang:html:list')); 17 | $this->assertMatchesRegularExpression(';crmUi/field.html;', $p->getOutput()); 18 | 19 | // matches key 20 | $p = Process::runOk($this->cv('ang:html:list crmUi')); 21 | $this->assertMatchesRegularExpression(';crmUi/field.html;', $p->getOutput()); 22 | 23 | // matches name 24 | $p = Process::runOk($this->cv('ang:html:list ";field;"')); 25 | $this->assertMatchesRegularExpression(';crmUi/field.html;', $p->getOutput()); 26 | 27 | // matches name 28 | $p = Process::runOk($this->cv('ang:html:list crmAttachment')); 29 | $this->assertDoesNotMatchRegularExpression(';crmUi/field.html;', $p->getOutput()); 30 | } 31 | 32 | /** 33 | * Get the extension data in an alternate format, eg JSON. 34 | */ 35 | public function testGetJson() { 36 | $p = Process::runOk($this->cv('ang:html:list crmUi/field.html --out=json')); 37 | $data = json_decode($p->getOutput(), 1); 38 | $this->assertEquals(1, count($data)); 39 | $this->assertEquals('crmUi/field.html', $data[0]['file']); 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /tests/Command/AngularHtmlShowCommandTest.php: -------------------------------------------------------------------------------- 1 | cv('php:eval \'return is_callable(array(Civi::service("angular"),"getRawPartials"));\'')); 15 | $supported = json_decode($p->getOutput(), 1); 16 | if (!$supported) { 17 | $this->markTestSkipped("Cannot test: this version of CiviCRM does not support getRawPartials"); 18 | } 19 | } 20 | 21 | /** 22 | * List extensions using a regular expression. 23 | */ 24 | public function testShow() { 25 | 26 | $p = Process::runOk($this->cv('ang:html:show crmUi/field.html')); 27 | $this->assertMatchesRegularExpression(';div.*ng-transclude;', $p->getOutput()); 28 | } 29 | 30 | /** 31 | * List extensions using a regular expression. 32 | */ 33 | public function testShowRaw() { 34 | $p = Process::runOk($this->cv('ang:html:show --raw crmUi/field.html')); 35 | $this->assertMatchesRegularExpression(';div.*ng-transclude;', $p->getOutput()); 36 | } 37 | 38 | /** 39 | * List extensions using a regular expression. 40 | */ 41 | public function testShowDiff() { 42 | // We don't know enough about the system to be sure of what diff to 43 | // expect, but we can at least ensure that it doesn't crash. 44 | $p = Process::runOk($this->cv('ang:html:show --diff crmUi/field.html')); 45 | $this->assertNotEmpty($p->getOutput()); 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /tests/Command/AngularModuleListCommandTest.php: -------------------------------------------------------------------------------- 1 | cv('ang:module:list')); 17 | $this->assertMatchesRegularExpression(';crmUi.*civicrm/a.*crmResource;', $p->getOutput()); 18 | 19 | $p = Process::runOk($this->cv('ang:module:list ";crm;"')); 20 | $this->assertMatchesRegularExpression(';crmUi.*civicrm/a.*crmResource;', $p->getOutput()); 21 | 22 | $p = Process::runOk($this->cv('ang:module:list ";foo;"')); 23 | $this->assertDoesNotMatchRegularExpression(';crmUi.*civicrm/a.*crmResource;', $p->getOutput()); 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /tests/Command/ApiBatchCommandTest.php: -------------------------------------------------------------------------------- 1 | 100)), 20 | array('Contact', 'get', array('id' => 101)), 21 | )) 22 | ); 23 | $p = Process::runOk(\Symfony\Component\Process\Process::fromShellCommandline("echo $input | {$this->cv} api:batch")); 24 | $data = json_decode($p->getOutput(), 1); 25 | $this->assertTrue(isset($data[0]['is_error'])); 26 | $this->assertTrue(isset($data[1]['is_error'])); 27 | $this->assertTrue(isset($data[0]['error_message']) || isset($data[0]['values'])); 28 | $this->assertTrue(isset($data[1]['error_message']) || isset($data[1]['values'])); 29 | } 30 | 31 | public function testApiBatch_MultiLine() { 32 | // For example API calls, don't care about ID# or existence -- just want a quick dummy call. 33 | $jsonNumArray = json_encode(array( 34 | array('Contact', 'get', array('id' => 100, 'options.ignore-me' => "foo\nbar")), 35 | array('Contact', 'get', array('id' => 101)), 36 | )); 37 | $jsonAssocArray = json_encode(array( 38 | 'foo' => array('Contact', 'get', array('id' => 100)), 39 | 'bar' => array('Contact', 'get', array('id' => 101)), 40 | )); 41 | $input = ($jsonNumArray . "\n" . $jsonAssocArray); 42 | 43 | $p = \Symfony\Component\Process\Process::fromShellCommandline("{$this->cv} api:batch"); 44 | $p->setInput($input); 45 | $p = Process::runOk($p); 46 | 47 | $this->assertEmpty($p->getErrorOutput()); 48 | $responses = explode("\n", $p->getOutput()); 49 | 50 | $dataNumArray = json_decode($responses[0], 1); 51 | $this->assertTrue(isset($dataNumArray[0]['is_error'])); 52 | $this->assertTrue(isset($dataNumArray[1]['is_error'])); 53 | $this->assertTrue(isset($dataNumArray[0]['error_message']) || isset($dataNumArray[0]['values'])); 54 | $this->assertTrue(isset($dataNumArray[1]['error_message']) || isset($dataNumArray[1]['values'])); 55 | 56 | $dataAssocArray = json_decode($responses[1], 1); 57 | $this->assertTrue(isset($dataAssocArray['foo']['is_error'])); 58 | $this->assertTrue(isset($dataAssocArray['bar']['is_error'])); 59 | $this->assertTrue(isset($dataAssocArray['foo']['error_message']) || isset($dataAssocArray['foo']['values'])); 60 | $this->assertTrue(isset($dataAssocArray['bar']['error_message']) || isset($dataAssocArray['bar']['values'])); 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /tests/Command/ApiCommandTest.php: -------------------------------------------------------------------------------- 1 | cv("api System.get")); 17 | $data = json_decode($p->getOutput(), 1); 18 | $this->assertTrue(!empty($data['values'])); 19 | foreach ($data['values'] as $row) { 20 | $this->assertTrue(!empty($row['version'])); 21 | $this->assertTrue(!empty($row['uf'])); 22 | } 23 | } 24 | 25 | public function testCsv() { 26 | $p = Process::runOk($this->cv("api OptionValue.get option_group_id=activity_type return=option_group_id,name rowCount=2 --out=csv")); 27 | $lines = explode("\n", trim($p->getOutput())); 28 | $expected = array( 29 | 'option_group_id,name', 30 | '2,Meeting', 31 | '2,"Phone Call"', 32 | ); 33 | $this->assertEquals($expected, $lines); 34 | } 35 | 36 | public function testCsvMisuse() { 37 | $p = Process::runOk($this->cv("api OptionValue.getsingle rowCount=1 --out=csv")); 38 | $this->assertMatchesRegularExpression('/The output format "csv" only works with tabular data. Try using a "get" API. Forcing format to "json-pretty"./', $p->getErrorOutput()); 39 | $data = json_decode($p->getOutput(), 1); 40 | $this->assertTrue(!empty($data['option_group_id'])); 41 | } 42 | 43 | public function testQuiet() { 44 | $p = Process::runOk($this->cv("api -q System.get")); 45 | $this->assertEmpty($p->getOutput()); 46 | $this->assertEmpty($p->getErrorOutput()); 47 | } 48 | 49 | public function testQuietError() { 50 | $p = Process::runFail($this->cv("api -q System.getzz")); 51 | $data = json_decode($p->getOutput(), 1); 52 | $this->assertTrue(!empty($data['is_error'])); 53 | $this->assertTrue(!empty($data['error_message'])); 54 | } 55 | 56 | public function testApiPipe() { 57 | $input = escapeshellarg(json_encode(array( 58 | 'options' => array('limit' => 1), 59 | ))); 60 | $p = Process::runOk(\Symfony\Component\Process\Process::fromShellCommandline("echo $input | {$this->cv} api Contact.get --in=json")); 61 | $data = json_decode($p->getOutput(), 1); 62 | $this->assertTrue(!empty($data['values'])); 63 | $this->assertEquals(1, count($data['values'])); 64 | foreach ($data['values'] as $row) { 65 | $this->assertTrue(!empty($row['display_name'])); 66 | } 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /tests/Command/BaseExtensionCommandTest.php: -------------------------------------------------------------------------------- 1 | configureRepoOptions(); 44 | 45 | $input = new ArgvInput($inputArgv, $c->getDefinition()); 46 | $this->assertEquals($expectUrl, $c->parseRepoUrl($input)); 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /tests/Command/BootCommandTest.php: -------------------------------------------------------------------------------- 1 | cv("php:boot --level=full")); 18 | $this->assertMatchesRegularExpression(';CIVICRM_SETTINGS_PATH;', $phpBoot->getOutput()); 19 | 20 | $helloPhp = escapeshellarg($phpBoot->getOutput() 21 | . 'printf("count is %s\n", CRM_Core_DAO::singleValueQuery("select count(*) from civicrm_contact"));' 22 | . 'printf("my admin is %s\n", $GLOBALS["_CV"]["ADMIN_USER"]);' 23 | ); 24 | $phpRun = Process::runOk(\Symfony\Component\Process\Process::fromShellCommandline("php -r $helloPhp")); 25 | $this->assertMatchesRegularExpression('/^count is [0-9]+\n/', $phpRun->getOutput()); 26 | $this->assertMatchesRegularExpression('/my admin is \w+\n/', $phpRun->getOutput()); 27 | } 28 | 29 | public function testBootCmsFull() { 30 | $phpBoot = Process::runOk($this->cv("php:boot --level=cms-full")); 31 | $this->assertMatchesRegularExpression(';BEGINPHP;', $phpBoot->getOutput()); 32 | $this->assertMatchesRegularExpression(';ENDPHP;', $phpBoot->getOutput()); 33 | 34 | $helloPhp = escapeshellarg($phpBoot->getOutput() 35 | . 'printf("count is %s\n", CRM_Core_DAO::singleValueQuery("select count(*) from civicrm_contact"));' 36 | . 'printf("my admin is %s\n", $GLOBALS["_CV"]["ADMIN_USER"]);' 37 | ); 38 | $phpRun = Process::runOk(\Symfony\Component\Process\Process::fromShellCommandline("php -r $helloPhp")); 39 | $this->assertMatchesRegularExpression('/^count is [0-9]+\n/', $phpRun->getOutput()); 40 | $this->assertMatchesRegularExpression('/my admin is \w+\n/', $phpRun->getOutput()); 41 | } 42 | 43 | public function testBootClassLoader() { 44 | $phpBoot = Process::runOk($this->cv("php:boot --level=classloader")); 45 | $this->assertMatchesRegularExpression(';ClassLoader;', $phpBoot->getOutput()); 46 | 47 | // In the classloader level, config vals like CIVICRM_DSN are not loaded. 48 | $helloPhp = escapeshellarg($phpBoot->getOutput() 49 | . '$x=array("a"=>defined("CIVICRM_DSN") ? "yup" : "nope");' 50 | . 'printf("phpr says %s\n", CRM_Utils_Array::value("a",$x));' 51 | ); 52 | $phpRun = Process::runOk(\Symfony\Component\Process\Process::fromShellCommandline("php -r $helloPhp")); 53 | $this->assertMatchesRegularExpression('/^phpr says nope$/', $phpRun->getOutput()); 54 | } 55 | 56 | public function testBootTest() { 57 | $phpBoot = Process::runOk($this->cv("php:boot --test")); 58 | $this->assertMatchesRegularExpression(';CIVICRM_SETTINGS_PATH;', $phpBoot->getOutput()); 59 | 60 | $helloPhp = escapeshellarg($phpBoot->getOutput() 61 | . 'echo CIVICRM_UF;' 62 | ); 63 | $phpRun = Process::runOk(\Symfony\Component\Process\Process::fromShellCommandline("php -ddisplay_errors=1 -r $helloPhp")); 64 | $this->assertMatchesRegularExpression('/UnitTests/i', $phpRun->getOutput()); 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /tests/Command/DebugContainerCommandTest.php: -------------------------------------------------------------------------------- 1 | cv("debug:container")); 17 | $this->assertMatchesRegularExpression('/(cxn_reg_client.*Civi.Cxn.Rpc.RegistrationClient|httpClient.*CRM_Utils_HttpClient|sql_triggers.*Civi.Core.SqlTrigger)/', $p->getOutput()); 18 | $this->assertMatchesRegularExpression('/civi_api_kernel.*Civi.API.Kernel/', $p->getOutput()); 19 | } 20 | 21 | // public function testName() { 22 | // $p = Process::runOk($this->cv("debug:container hook_civicrm_caseChange")); 23 | // $this->assertMatchesRegularExpression('/hook_civicrm_caseChange/', $p->getOutput()); 24 | // $this->assertDoesNotMatchRegularExpression('/hook_civicrm_post/', $p->getOutput()); 25 | // $this->assertDoesNotMatchRegularExpression('/civi.token.eval/', $p->getOutput()); 26 | // } 27 | 28 | // public function testRegExp() { 29 | // $p = Process::runOk($this->cv("debug:container /^hook/")); 30 | // $this->assertMatchesRegularExpression('/hook_civicrm_caseChange/', $p->getOutput()); 31 | // $this->assertMatchesRegularExpression('/hook_civicrm_post/', $p->getOutput()); 32 | // $this->assertDoesNotMatchRegularExpression('/civi.token.eval/', $p->getOutput()); 33 | // } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /tests/Command/DebugDispatcherCommandTest.php: -------------------------------------------------------------------------------- 1 | cv("debug:event-dispatcher")); 17 | $this->assertMatchesRegularExpression('/hook_civicrm_caseChange/', $p->getOutput()); 18 | $this->assertMatchesRegularExpression('/hook_civicrm_post/', $p->getOutput()); 19 | $this->assertMatchesRegularExpression('/civi.token.eval/', $p->getOutput()); 20 | } 21 | 22 | public function testName() { 23 | $p = Process::runOk($this->cv("debug:event-dispatcher hook_civicrm_caseChange")); 24 | $this->assertMatchesRegularExpression('/hook_civicrm_caseChange/', $p->getOutput()); 25 | $this->assertDoesNotMatchRegularExpression('/hook_civicrm_post/', $p->getOutput()); 26 | $this->assertDoesNotMatchRegularExpression('/civi.token.eval/', $p->getOutput()); 27 | } 28 | 29 | public function testRegExp() { 30 | $p = Process::runOk($this->cv("debug:event-dispatcher /^hook/")); 31 | $this->assertMatchesRegularExpression('/hook_civicrm_caseChange/', $p->getOutput()); 32 | $this->assertMatchesRegularExpression('/hook_civicrm_post/', $p->getOutput()); 33 | $this->assertDoesNotMatchRegularExpression('/civi.token.eval/', $p->getOutput()); 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /tests/Command/ExtensionBareDownloadTest.php: -------------------------------------------------------------------------------- 1 | tmpDir = sys_get_temp_dir() . '/baredl'; 19 | 20 | if (self::$first) { 21 | self::$first = FALSE; 22 | $this->cleanup(); 23 | } 24 | 25 | $this->removeDir($this->tmpDir); 26 | mkdir($this->tmpDir); 27 | chdir($this->tmpDir); 28 | } 29 | 30 | public function tearDown(): void { 31 | parent::tearDown(); 32 | $this->cleanup(); 33 | } 34 | 35 | /** 36 | * Download an extension using an explicit URL. 37 | */ 38 | public function testDownloadBare() { 39 | $toPath = $this->tmpDir . '/extracted-ext'; 40 | 41 | $origFile = dirname(__DIR__) . '/fixtures/org.example.cvtest/info.xml'; 42 | $finalFile = "$toPath/info.xml"; 43 | $this->assertFalse(file_exists($finalFile), "File $finalFile should not yet exist."); 44 | 45 | $cvTestZip = $this->makeCvTestZip(); 46 | $infoXmlPath = $this->makeDownloadManifest($origFile, 'file://' . $cvTestZip); 47 | 48 | $extSpecArg = escapeshellarg("@" . $infoXmlPath); 49 | $toArg = escapeshellarg($toPath); 50 | Process::runOk($this->cv("ext:download -b $extSpecArg --to=$toArg")); 51 | 52 | $this->assertTrue(file_exists($finalFile), "File $finalFile should exist."); 53 | $this->assertEquals(file_get_contents($origFile), file_get_contents($finalFile)); 54 | } 55 | 56 | protected function makeDownloadManifest($origFile, $downloadUrl) { 57 | $xml = simplexml_load_string(file_get_contents($origFile)); 58 | $xml->addChild('downloadUrl', $downloadUrl); 59 | $newFile = $this->tmpDir . DIRECTORY_SEPARATOR . 'fixme.xml'; 60 | file_put_contents($newFile, $xml->saveXML()); 61 | return $newFile; 62 | } 63 | 64 | /** 65 | * Make a zip file for the placeholder extension, `org.example.cvtest`. 66 | * @return string 67 | * Path to zip file 68 | */ 69 | protected function makeCvTestZip() { 70 | $cvTestSrc = dirname(__DIR__) . '/fixtures/org.example.cvtest'; 71 | $makePhp = $cvTestSrc . DIRECTORY_SEPARATOR . 'make.php'; 72 | $cvTestZip = $this->tmpDir . DIRECTORY_SEPARATOR . 'cvtest.zip'; 73 | Process::runOk(\Symfony\Component\Process\Process::fromShellCommandline( 74 | escapeshellcmd($makePhp) . ' ' . escapeshellarg($cvTestZip), 75 | $cvTestSrc 76 | )); 77 | return $cvTestZip; 78 | } 79 | 80 | /** 81 | * @param string $dir 82 | */ 83 | protected function removeDir($dir) { 84 | if (!empty($dir) && file_exists($dir) && is_dir($dir)) { 85 | exec("rm -rf " . escapeshellarg($dir)); 86 | } 87 | } 88 | 89 | protected function cleanup() { 90 | $this->removeDir($this->tmpDir); 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /tests/Command/FillCommandTest.php: -------------------------------------------------------------------------------- 1 | cv("vars:fill --file=/dev/stdin"); 13 | $p->setInput(json_encode(array( 14 | 'ADMIN_USER' => 'admin', 15 | ))); 16 | $p->setEnv(array( 17 | 'CV_CONFIG' => $tmpConfigFile, 18 | )); 19 | $p->run(); 20 | 21 | $config = json_decode(file_get_contents($tmpConfigFile), 1); 22 | unlink($tmpConfigFile); 23 | 24 | $this->assertMatchesRegularExpression('/Please edit.*' . preg_quote($tmpConfigFile, '/') . '/', $p->getOutput()); 25 | $this->assertNotEmpty($config); 26 | $this->assertNotEmpty($config['sites']); 27 | foreach ($config['sites'] as $path => $siteConfig) { 28 | $this->assertEquals('t0ps3cr3t', $siteConfig['ADMIN_PASS']); 29 | $this->assertTrue(!isset($siteConfig['ADMIN_USER'])); 30 | } 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /tests/Command/FlushCommandTest.php: -------------------------------------------------------------------------------- 1 | cv("flush")); 17 | $this->assertMatchesRegularExpression('/Flushing/', $p->getOutput()); 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /tests/Command/HttpCommandTest.php: -------------------------------------------------------------------------------- 1 | cvOk('en authx'); 23 | } 24 | 25 | public function testAuthorizedGet() { 26 | $body = $this->cvOk("http {$this->login} civicrm/authx/id"); 27 | $data = json_decode($body, TRUE); 28 | $this->assertTrue(is_numeric($data['contact_id']), "civicrm/authx/id should return current contact. Received: $body"); 29 | 30 | $body = $this->cvOk("http {$this->login} 'civicrm/user?reset=1'"); 31 | $this->assertMatchesRegularExpression(':assertMatchesRegularExpression(';Your Group;', $body); 33 | } 34 | 35 | public function testUnauthorizedGet() { 36 | $body = $this->cvOk("http civicrm/authx/id"); 37 | $data = json_decode($body, TRUE); 38 | $this->assertTrue(empty($data['contact_id']), "civicrm/authx/id should return anonymous. Received: $body"); 39 | 40 | $body = $this->cvFail("http 'civicrm/user?reset=1'"); 41 | $this->assertMatchesRegularExpression(':assertDoesNotMatchRegularExpression(';Your Group;', $body); 43 | } 44 | 45 | public function testGetWebService() { 46 | $data = escapeshellarg('params=' . urlencode(json_encode(['limit' => 1]))); 47 | $body = $this->cvOk("http {$this->login} civicrm/ajax/api4/Group/get --data $data"); 48 | $parsed = json_decode($body, TRUE); 49 | $this->assertTrue(!empty($parsed['values'][0]['title']) && is_string($parsed['values'][0]['title']), 50 | 'Response should include a title. Received: ' . $body); 51 | $this->assertEquals(1, count($parsed['values']), "Response should have been limited to 1 record."); 52 | } 53 | 54 | public function testGetWebServiceVerbose() { 55 | $data = escapeshellarg('params=' . urlencode(json_encode(['limit' => 1]))); 56 | $p = Process::runOk($this->cv("http -v {$this->login} civicrm/ajax/api4/Group/get --data $data")); 57 | 58 | $parsed = json_decode($p->getOutput(), TRUE); 59 | $this->assertTrue(!empty($parsed['values'][0]['title']) && is_string($parsed['values'][0]['title']), 60 | 'Response should include a title. Received: ' . $p->getOutput()); 61 | $this->assertEquals(1, count($parsed['values']), "Response should have been limited to 1 record."); 62 | 63 | $error = $p->getErrorOutput(); 64 | $this->assertMatchesRegularExpression(';> POST http;', $error); 65 | $this->assertMatchesRegularExpression(';> Content-Type: application/x-www-form-urlencoded;', $error); 66 | $this->assertMatchesRegularExpression(';> X-Civi-Auth: Bearer;', $error); 67 | $this->assertMatchesRegularExpression(';< Content-Type: application/json;', $error); 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /tests/Command/PathCommandTest.php: -------------------------------------------------------------------------------- 1 | cv('path')); 17 | $this->assertMatchesRegularExpression('/Must use -x, -c, or -d/', $p->getErrorOutput()); 18 | } 19 | 20 | public function testExtPaths() { 21 | $vars = $this->cvJsonOk('vars:show'); 22 | $this->assertTrue(is_dir($vars['CIVI_CORE'])); 23 | $this->assertTrue(file_exists($vars['CIVI_CORE'])); 24 | 25 | // Try "cv path -x ". 26 | $plain = rtrim($this->cvOk("path -x civicrm"), "\n"); 27 | $this->assertEquals(rtrim($vars['CIVI_CORE'], '/'), $plain); 28 | 29 | $plain = rtrim($this->cvOk("path -x civicrm/"), "\n"); 30 | $this->assertEquals($vars['CIVI_CORE'], $plain); 31 | 32 | $plain = rtrim($this->cvOk("path -x civicrm/packages"), "\n"); 33 | $this->assertEquals($vars['CIVI_CORE'] . 'packages', $plain); 34 | 35 | $json = $this->cvJsonOk("path -x civicrm --out=json"); 36 | $this->assertEquals('ext', $json[0]['type']); 37 | $this->assertEquals('civicrm', $json[0]['expr']); 38 | $this->assertEquals(rtrim($vars['CIVI_CORE'], '/'), $json[0]['value']); 39 | } 40 | 41 | public function testDynamicExprPaths() { 42 | $vars = $this->cvJsonOk('vars:show'); 43 | $this->assertTrue(is_dir($vars['CIVI_CORE'])); 44 | $this->assertTrue(file_exists($vars['CIVI_CORE'])); 45 | 46 | if (version_compare($vars['CIVI_VERSION'], '4.7.0', '<')) { 47 | $this->markTestSkipped('"cv path -d" requires v4.7+'); 48 | } 49 | 50 | $plain = rtrim($this->cvOk("path -d '[civicrm.root]'"), "\n"); 51 | $this->assertEquals(rtrim($vars['CIVI_CORE'], '/'), $plain); 52 | 53 | $plain = rtrim($this->cvOk("path -d '[civicrm.root]/'"), "\n"); 54 | $this->assertEquals($vars['CIVI_CORE'], $plain); 55 | 56 | $plain = rtrim($this->cvOk("path -d '[civicrm.root]/packages'"), "\n"); 57 | $this->assertEquals($vars['CIVI_CORE'] . 'packages', $plain); 58 | 59 | $json = $this->cvJsonOk("path -d '[civicrm.root]/packages/DB.php' --out=json"); 60 | $this->assertEquals('dynamic', $json[0]['type']); 61 | $this->assertEquals('[civicrm.root]/packages/DB.php', $json[0]['expr']); 62 | $this->assertEquals($vars['CIVI_CORE'] . 'packages/DB.php', $json[0]['value']); 63 | } 64 | 65 | public function testConfigPaths() { 66 | $vars = $this->cvJsonOk('vars:show'); 67 | $this->assertTrue(is_dir($vars['CIVI_CORE'])); 68 | $this->assertTrue(file_exists($vars['CIVI_CORE'])); 69 | 70 | $mandatorySettingNames = array( 71 | 'configAndLogDir', 72 | 'extensionsDir', 73 | 'imageUploadDir', 74 | 'templateCompileDir', 75 | 'uploadDir', 76 | ); 77 | foreach ($mandatorySettingNames as $settingName) { 78 | $plain = rtrim($this->cvOk("path -c $settingName"), "\n"); 79 | $this->assertTrue(file_exists($plain) && is_dir($plain), "Check $settingName"); 80 | } 81 | 82 | $optionalSettingNames = array( 83 | 'customFileUploadDir', 84 | 'customPHPPathDir', 85 | 'customTemplateDir', 86 | 'templateCompileDir/en_US', 87 | ); 88 | foreach ($optionalSettingNames as $settingName) { 89 | $plain = rtrim($this->cvOk("path -c $settingName"), "\n"); 90 | $this->assertTrue((file_exists($plain) && is_dir($plain)) || empty($plain), "Check $settingName"); 91 | } 92 | } 93 | 94 | public function testExtDot() { 95 | $this->assertEquals( 96 | $this->cvOk('path -x.'), 97 | $this->cvOk('path -c extensionsDir') 98 | ); 99 | $this->assertEquals( 100 | $this->cvOk('path -x .'), 101 | $this->cvOk('path -c extensionsDir') 102 | ); 103 | } 104 | 105 | } 106 | -------------------------------------------------------------------------------- /tests/Command/ScriptCommandTest.php: -------------------------------------------------------------------------------- 1 | cv("scr $helloPhp")); 19 | $this->assertMatchesRegularExpression('/^version [0-9a-z\.]+$/', $p->getOutput()); 20 | } 21 | 22 | public function testPhpScript() { 23 | $helloPhp = escapeshellarg(__DIR__ . '/hello-world.php'); 24 | $p = Process::runOk($this->cv("php:script $helloPhp")); 25 | $this->assertMatchesRegularExpression('/^version [0-9a-z\.]+$/', $p->getOutput()); 26 | } 27 | 28 | public function testScrNoArg() { 29 | $helloPhpFile = __DIR__ . '/hello-args.php'; 30 | $helloPhpEsc = escapeshellarg(__DIR__ . '/hello-args.php'); 31 | $p = Process::runOk($this->cv("scr $helloPhpEsc")); 32 | $this->assertEquals("Count: 1\n0: $helloPhpFile\n", $p->getOutput()); 33 | } 34 | 35 | public function testScrArgs() { 36 | $helloPhpFile = __DIR__ . '/hello-args.php'; 37 | $helloPhpEsc = escapeshellarg(__DIR__ . '/hello-args.php'); 38 | $p = Process::runOk($this->cv("scr $helloPhpEsc one 'two and' three")); 39 | $this->assertEquals("Count: 4\n0: $helloPhpFile\n1: one\n2: two and\n3: three\n", $p->getOutput()); 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /tests/Command/ShowCommandTest.php: -------------------------------------------------------------------------------- 1 | cv("vars:show"); 15 | $p->run(); 16 | $data = json_decode($p->getOutput(), 1); 17 | $this->assertMatchesRegularExpression('/^([0-9\.\-]|alpha|beta|dev|master|x)+$/', $data['CIVI_VERSION']); 18 | $this->assertMatchesRegularExpression('/^([0-9\.\-]|alpha|beta|dev|master|x)+$/', $data['CMS_VERSION']); 19 | $this->assertTrue(is_dir($data['CMS_ROOT'])); 20 | $this->assertTrue(is_dir($data['CIVI_CORE'])); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /tests/Command/SqlCliCommandTest.php: -------------------------------------------------------------------------------- 1 | cv("sql")->setInput($query)); 18 | $this->assertMatchesRegularExpression('/id\s+display_name/', $p->getOutput()); 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /tests/Command/StatusCommandTest.php: -------------------------------------------------------------------------------- 1 | cv("status"); 15 | $p->run(); 16 | $data = $p->getOutput(); 17 | $this->assertTrue((bool) preg_match('/| php .* | \d+\.\d+\./', $data)); 18 | } 19 | 20 | public function testStatusJson() { 21 | $p = $this->cv("status --out=json"); 22 | $p->run(); 23 | $data = json_decode($p->getOutput(), 1); 24 | $this->assertTrue((bool) preg_match('/^\d+\.\d+\./', $data['civicrm']['value'])); 25 | $this->assertTrue((bool) preg_match('/^\d+\.\d+\./', $data['php']['value'])); 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /tests/Command/UpgradeGetCommandTest.php: -------------------------------------------------------------------------------- 1 | upgradeRun('stable'); 12 | $this->assertMatchesRegularExpression('/\/civicrm-' . $data['version'] . '-(drupal[68]?|joomla|wordpress)+\.(tar\.gz|zip)$/', $data['url']); 13 | $this->stableVersionCheck($data); 14 | } 15 | 16 | public function testGetStableWordpress() { 17 | $data = $this->upgradeRun('stable', 'WordPress'); 18 | $this->stableVersionCheck($data); 19 | } 20 | 21 | public function testGetStableJoomla() { 22 | $data = $this->upgradeRun('stable', 'Joomla'); 23 | $this->stableVersionCheck($data); 24 | } 25 | 26 | public function testGetStableDrupal() { 27 | $data = $this->upgradeRun('stable', 'Drupal'); 28 | $this->stableVersionCheck($data); 29 | } 30 | 31 | public function testGetStableBackdrop() { 32 | $data = $this->upgradeRun('stable', 'Backdrop'); 33 | $this->stableVersionCheck($data); 34 | } 35 | 36 | public function testGetNightly() { 37 | $data = $this->upgradeRun('nightly'); 38 | $revisionId = $this->analyzeRevision($data); 39 | $this->assertGreaterThanOrEqual((int) date('Ymdhi', strtotime('-2 days')), (int) $revisionId, 'Nightly revision is well over a day old.'); 40 | } 41 | 42 | public function testGetRc() { 43 | $data = $this->upgradeRun('rc'); 44 | $revisionId = $this->analyzeRevision($data); 45 | } 46 | 47 | protected function stableVersionCheck($data) { 48 | $this->assertMatchesRegularExpression('/^\d+\.\d+\.\d+$/', $data['version']); 49 | $this->assertEquals($data['version'], $data['rev'], 'Stable revision is not the same as version number.'); 50 | } 51 | 52 | protected function analyzeRevision($data) { 53 | $this->assertMatchesRegularExpression('/\d+\.\d+\.\d+$/', $data['version']); 54 | $this->assertMatchesRegularExpression('/' . $data['version'] . '-\d+$/', $data['rev'], 'Revision name does not include version number.'); 55 | $revisionParts = explode('-', $data['rev']); 56 | array_unshift($revisionParts, 'civicrm'); 57 | $revisionId = array_pop($revisionParts); 58 | $revisionParts[] = '(drupal[68]?|joomla|wordpress)'; 59 | $revisionParts[] = $revisionId; 60 | $this->assertMatchesRegularExpression('/\/' . implode('-', $revisionParts) . '\.(tar\.gz|zip)$/', $data['url']); 61 | $this->assertLessThanOrEqual((int) date('Ymdhi', strtotime('+1 day')), (int) $revisionId, 'Revision is from the future.'); 62 | return $revisionId; 63 | } 64 | 65 | protected function upgradeRun($stability, $cms = NULL) { 66 | $cmsString = ($cms) ? " --cms=$cms" : ''; 67 | $p = $this->cv("upgrade:get --stability=$stability$cmsString"); 68 | $p->run(); 69 | $data = json_decode($p->getOutput(), 1); 70 | $this->assertTrue(isset($data['url']), 'Looking for url in output: ' . $p->getOutput()); 71 | switch ($cms) { 72 | case 'WordPress': 73 | case 'Joomla': 74 | $this->assertMatchesRegularExpression('/-' . strtolower($cms) . '\.zip/', $data['url']); 75 | break; 76 | 77 | case 'Drupal': 78 | case 'Drupal6': 79 | case 'Drupal8': 80 | case 'Backdrop': 81 | $this->assertMatchesRegularExpression('/-' . strtolower($cms) . '\.tar\.gz/', $data['url']); 82 | } 83 | $headers = get_headers($data['url']); 84 | $this->assertContains('200 OK', $headers[0], 'URL does not have the file to download.'); 85 | return $data; 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /tests/Command/hello-args.php: -------------------------------------------------------------------------------- 1 | $val) { 8 | print "$key: $val\n"; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/Command/hello-world.php: -------------------------------------------------------------------------------- 1 | addListener('myapp.foo', function ($event) { 25 | static::addLog('myapp.foo'); 26 | }); 27 | $d->addListener('myapp.bar', function ($event) { 28 | static::addLog('unrelated'); 29 | }); 30 | $d->dispatch(new CvEvent(['data' => 'yoyo']), 'myapp.foo')->getArguments(); 31 | $this->assertEquals(['myapp.foo'], static::$log); 32 | } 33 | 34 | public function testCallbackTypes() { 35 | $d = new CvDispatcher(); 36 | $d->addListener('myapp.foo', function ($event) { 37 | static::addLog("foo one data=" . $event['data']); 38 | }); 39 | $d->addListener('myapp.foo', __NAMESPACE__ . '\\cvdispatcher_global_func'); 40 | $d->addListener('myapp.foo', [__CLASS__, 'addFooThree']); 41 | 42 | $r = $d->dispatch(new CvEvent(['data' => 'yoyo']), 'myapp.foo')->getArguments(); 43 | $this->assertEquals('yoyo', $r['data']); 44 | $this->assertEquals([ 45 | 'foo one data=yoyo', 46 | 'called Civi\\Cv\\cvdispatcher_global_func', 47 | 'foo three data=yoyo', 48 | ], static::$log); 49 | } 50 | 51 | public function testAlter() { 52 | $d = new CvDispatcher(); 53 | $d->addListener('myapp.foo', function ($event) { 54 | $event['data'] .= ' first'; 55 | }); 56 | $d->addListener('myapp.foo', function ($event) { 57 | $event['data'] .= ' second'; 58 | }); 59 | $r = $d->dispatch(new CvEvent(['data' => 'seed']), 'myapp.foo')->getArguments(); 60 | $this->assertEquals('seed first second', $r['data']); 61 | } 62 | 63 | public function testPriority() { 64 | $d = new CvDispatcher(); 65 | $d->addListener('myapp.foo', function ($event) { 66 | static::addLog('foo.3.1'); 67 | }, 3); 68 | $d->addListener('myapp.foo', function ($event) { 69 | static::addLog('foo.-200.1'); 70 | }, -200); 71 | $d->addListener('myapp.foo', function ($event) { 72 | static::addLog('foo.1.1'); 73 | }, 1); 74 | $d->addListener('myapp.foo', function ($event) { 75 | static::addLog('foo.2.1'); 76 | }, 2); 77 | $d->addListener('myapp.foo', function ($event) { 78 | static::addLog('foo.1.2'); 79 | }, 1); 80 | $d->addListener('myapp.foo', function ($event) { 81 | static::addLog('foo.1.3'); 82 | }, 1); 83 | $d->addListener('*.foo', function ($event) { 84 | static::addLog('wildFoo.1.1'); 85 | }, 1); 86 | $d->addListener('*.foo', function ($event) { 87 | static::addLog('wildFoo.2.1'); 88 | }, 2); 89 | 90 | $d->dispatch(new CvEvent([]), 'myapp.foo'); 91 | $this->assertEquals([ 92 | 'foo.-200.1', 93 | 'foo.1.1', 94 | 'foo.1.2', 95 | 'foo.1.3', 96 | 'wildFoo.1.1', 97 | 'foo.2.1', 98 | 'wildFoo.2.1', 99 | 'foo.3.1', 100 | ], static::$log); 101 | } 102 | 103 | public static function addLog(string $message) { 104 | static::$log[] = $message; 105 | } 106 | 107 | public static function addFooThree($event) { 108 | static::addLog("foo three data=" . $event['data']); 109 | } 110 | 111 | } 112 | -------------------------------------------------------------------------------- /tests/CvTestTrait.php: -------------------------------------------------------------------------------- 1 | getCvPath(); 17 | $process = \Symfony\Component\Process\Process::fromShellCommandline("{$cvPath} $command"); 18 | return $process; 19 | } 20 | 21 | /** 22 | * Run a `cv` subcommand. Assert success and return output. 23 | * 24 | * @param string $cmd 25 | * @return string 26 | */ 27 | protected function cvOk($cmd) { 28 | $p = Process::runOk($this->cv($cmd)); 29 | return $p->getOutput(); 30 | } 31 | 32 | /** 33 | * Run a `cv` subcommand. Assert success and return output. 34 | * 35 | * @param string $cmd 36 | * @return string 37 | */ 38 | protected function cvFail($cmd) { 39 | $p = Process::runFail($this->cv($cmd)); 40 | return $p->getErrorOutput() . $p->getOutput(); 41 | } 42 | 43 | /** 44 | * Run a `cv` subcommand. Assert success and return output. 45 | * 46 | * @param string $cmd 47 | * @return string 48 | */ 49 | protected function cvJsonOk($cmd) { 50 | $p = Process::runOk($this->cv($cmd)); 51 | return json_decode($p->getOutput(), 1); 52 | } 53 | 54 | private function getCvPath(): string { 55 | return getenv('CV_TEST_BINARY') ?: dirname(__DIR__) . '/bin/cv'; 56 | } 57 | 58 | protected function isCvPharTest(): bool { 59 | return (bool) preg_match(';\.phar$;', $this->getCvPath()); 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /tests/Plugin/AliasPluginTest.php: -------------------------------------------------------------------------------- 1 | setEnv(['CV_PLUGIN_PATH' => preg_replace(';\.php$;', '', __FILE__)]); 17 | return $process; 18 | } 19 | 20 | public function testDummyAlias() { 21 | $output = $this->cvOk('@dummy ext:list -Li'); 22 | $this->assertMatchesRegularExpression(";^DUMMY: '.*/(cv|cv.phar)' --site-alias=dummy 'ext:list' -Li;", $output); 23 | } 24 | 25 | public function testUnknownAlias() { 26 | $output = $this->cvFail('@eldorado ext:list -Li'); 27 | $this->assertMatchesRegularExpression('/Unknown site alias: eldorado/', $output); 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /tests/Plugin/AliasPluginTest/dummy-alias.php: -------------------------------------------------------------------------------- 1 | 1) { 17 | die("Expect CV_PLUGIN API v1"); 18 | } 19 | 20 | Cv::dispatcher()->addListener('*.app.site-alias', function(CvEvent $event) { 21 | if ($event['alias'] === 'dummy') { 22 | 23 | foreach (['app', 'output', 'input'] as $key) { 24 | if (empty($event[$key])) { 25 | throw new \RuntimeException("Event *.app.site-alias is missing value for \"$key\""); 26 | } 27 | } 28 | 29 | /** 30 | * @var \Civi\Cv\Util\CvArgvInput $input 31 | */ 32 | $input = $event['input']; 33 | 34 | /** 35 | * @var \CvDeps\Symfony\Component\Console\Output\OutputInterface $output 36 | */ 37 | $output = $event['output']; 38 | 39 | $args = array_map(__NAMESPACE__ . '\\escapeString', $input->getOriginalArgv()); 40 | $fullCommand = implode(' ', $args); 41 | 42 | $event['transport'] = function() use ($input, $output, $fullCommand) { 43 | $output->writeln("DUMMY: $fullCommand", OutputInterface::OUTPUT_RAW); 44 | }; 45 | } 46 | }); 47 | 48 | function escapeString(string $expr): string { 49 | return preg_match('{^[\w=-]+$}', $expr) ? $expr : escapeshellarg($expr); 50 | } 51 | -------------------------------------------------------------------------------- /tests/Plugin/FluentHelloPluginTest.php: -------------------------------------------------------------------------------- 1 | setEnv(['CV_PLUGIN_PATH' => preg_replace(';\.php$;', '', __FILE__)]); 16 | return $process; 17 | } 18 | 19 | public function testRun() { 20 | $output = $this->cvOk('hello:normal'); 21 | $this->assertMatchesRegularExpression('/Hey-yo world via parameter.*Hey-yo world via StyleInterface/s', $output); 22 | } 23 | 24 | public function testRunWithName() { 25 | $output = $this->cvOk('hello:normal Alice'); 26 | $this->assertMatchesRegularExpression('/Hey-yo Alice via parameter.*Hey-yo Alice via StyleInterface/s', $output); 27 | } 28 | 29 | public function testRun_noboot() { 30 | $output = $this->cvOk('hello:noboot'); 31 | $this->assertMatchesRegularExpression('/Hey-yo world via parameter.*Hey-yo world via StyleInterface/s', $output); 32 | } 33 | 34 | public function testRunWithName_noboot() { 35 | $output = $this->cvOk('hello:noboot Bob'); 36 | $this->assertMatchesRegularExpression('/Hey-yo Bob via parameter.*Hey-yo Bob via StyleInterface/s', $output); 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /tests/Plugin/FluentHelloPluginTest/hello.php: -------------------------------------------------------------------------------- 1 | 1) { 9 | die("Expect CV_PLUGIN API v1"); 10 | } 11 | 12 | if (!preg_match(';^[\w_-]+$;', $CV_PLUGIN['appName'])) { 13 | throw new \RuntimeException("Invalid CV_PLUGIN[appName]" . json_encode($CV_PLUGIN['appName'])); 14 | } 15 | 16 | if (!preg_match(';^([0-9x\.]+(-[\w-]+)?|UNKNOWN)$;', $CV_PLUGIN['appVersion'])) { 17 | throw new \RuntimeException("Invalid CV_PLUGIN[appVersion]: " . json_encode($CV_PLUGIN['appVersion'])); 18 | } 19 | 20 | if ($CV_PLUGIN['name'] !== 'hello') { 21 | throw new \RuntimeException("Invalid CV_PLUGIN[name]"); 22 | } 23 | if (realpath($CV_PLUGIN['file']) !== realpath(__FILE__)) { 24 | throw new \RuntimeException("Invalid CV_PLUGIN[file]"); 25 | } 26 | 27 | Cv::dispatcher()->addListener('*.app.boot', function ($e) { 28 | Cv::io()->writeln("Hey-yo during initial bootstrap!"); 29 | }); 30 | 31 | Cv::dispatcher()->addListener('cv.app.commands', function ($e) { 32 | 33 | $e['commands'][] = (new CvCommand('hello:normal')) 34 | ->setDescription('Say a greeting') 35 | ->addArgument('name') 36 | ->setCode(function($input, $output) { 37 | // ASSERT: With setCode(), it's OK to use un-hinted inputs. 38 | if ($input->getArgument('name') !== Cv::input()->getArgument('name')) { 39 | throw new \RuntimeException("Argument \"name\" is inconsistent!"); 40 | } 41 | if (!Civi\Core\Container::isContainerBooted()) { 42 | throw new \LogicException("Container should have been booted by CvCommand!"); 43 | } 44 | $name = $input->getArgument('name') ?: 'world'; 45 | $output->writeln("Hey-yo $name via parameter!"); 46 | Cv::io()->writeln("Hey-yo $name via StyleInterface!"); 47 | return 0; 48 | }); 49 | 50 | $e['commands'][] = (new CvCommand('hello:noboot')) 51 | ->setDescription('Say a greeting') 52 | ->addArgument('name') 53 | ->setBootOptions(['auto' => FALSE]) 54 | ->setCode(function(InputInterface $input, OutputInterface $output) { 55 | // ASSERT: With setCode(), it's OK to use hinted inputs. 56 | if ($input->getArgument('name') !== Cv::input()->getArgument('name')) { 57 | throw new \RuntimeException("Argument \"name\" is inconsistent!"); 58 | } 59 | if (class_exists('Civi\Core\Container')) { 60 | throw new \LogicException("Container should not have been booted by CvCommand!"); 61 | } 62 | $name = $input->getArgument('name') ?: 'world'; 63 | $output->writeln("Hey-yo $name via parameter!"); 64 | Cv::io()->writeln("Hey-yo $name via StyleInterface!"); 65 | return 0; 66 | }); 67 | 68 | }); 69 | -------------------------------------------------------------------------------- /tests/Plugin/HelloPluginTest.php: -------------------------------------------------------------------------------- 1 | setEnv(['CV_PLUGIN_PATH' => preg_replace(';\.php$;', '', __FILE__)]); 16 | return $process; 17 | } 18 | 19 | public function testRun() { 20 | $output = $this->cvOk('hello'); 21 | $this->assertMatchesRegularExpression('/Hello world via parameter.*Hello world via StyleInterface/s', $output); 22 | } 23 | 24 | public function testRunWithName() { 25 | $output = $this->cvOk('hello Bob'); 26 | $this->assertMatchesRegularExpression('/Hello Bob via parameter.*Hello Bob via StyleInterface/s', $output); 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /tests/Plugin/HelloPluginTest/hello.php: -------------------------------------------------------------------------------- 1 | 1) { 9 | die("Expect CV_PLUGIN API v1"); 10 | } 11 | 12 | if (!preg_match(';^[\w_-]+$;', $CV_PLUGIN['appName'])) { 13 | throw new \RuntimeException("Invalid CV_PLUGIN[appName]" . json_encode($CV_PLUGIN['appName'])); 14 | } 15 | 16 | if (!preg_match(';^([0-9x\.]+(-[\w-]+)?|UNKNOWN)$;', $CV_PLUGIN['appVersion'])) { 17 | throw new \RuntimeException("Invalid CV_PLUGIN[appVersion]: " . json_encode($CV_PLUGIN['appVersion'])); 18 | } 19 | 20 | if ($CV_PLUGIN['name'] !== 'hello') { 21 | throw new \RuntimeException("Invalid CV_PLUGIN[name]"); 22 | } 23 | if (realpath($CV_PLUGIN['file']) !== realpath(__FILE__)) { 24 | throw new \RuntimeException("Invalid CV_PLUGIN[file]"); 25 | } 26 | 27 | Cv::dispatcher()->addListener('*.app.boot', function ($e) { 28 | Cv::io()->writeln("Hello during initial bootstrap!"); 29 | }); 30 | 31 | Cv::dispatcher()->addListener('cv.app.commands', function ($e) { 32 | $e['commands'][] = new class extends Command { 33 | 34 | protected function configure() { 35 | $this->setName('hello')->setDescription('Say a greeting')->addArgument('name'); 36 | } 37 | 38 | protected function execute(InputInterface $input, OutputInterface $output): int { 39 | if ($input->getArgument('name') !== Cv::input()->getArgument('name')) { 40 | throw new \RuntimeException("Argument \"name\" is inconsistent!"); 41 | } 42 | $name = $input->getArgument('name') ?: 'world'; 43 | $output->writeln("Hello $name via parameter!"); 44 | Cv::io()->writeln("Hello $name via StyleInterface!"); 45 | return 0; 46 | } 47 | 48 | }; 49 | }); 50 | -------------------------------------------------------------------------------- /tests/TopHelperTest.php: -------------------------------------------------------------------------------- 1 | isCvPharTest()) { 17 | $exprs = [ 18 | 'Cvphar\Fruit\Apple' => '\Fruit\Apple', 19 | '\Cvphar\Fruit\Banana' => '\Fruit\Banana', 20 | 'Fruit\Cherry' => '\Fruit\Cherry', 21 | '\Fruit\Date' => '\Fruit\Date', 22 | ]; 23 | } 24 | else { 25 | $exprs = [ 26 | 'Fruit\Apple' => '\Fruit\Apple', 27 | '\Fruit\Banana' => '\Fruit\Banana', 28 | ]; 29 | } 30 | 31 | foreach ($exprs as $input => $expected) { 32 | $p = Process::runOk($this->cv("ev 'return \Civi\Cv\Top::symbol(getenv(\"SYMBOL\"));'") 33 | ->setEnv(['SYMBOL' => $input])); 34 | $actual = json_decode($p->getOutput()); 35 | $this->assertEquals($expected, $actual, "Input ($input) should yield value ($expected)."); 36 | } 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /tests/Util/AliasFilterTest.php: -------------------------------------------------------------------------------- 1 | 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.'); 54 | // $command->addOption('to', NULL, InputOption::VALUE_OPTIONAL, 'Download to a specific directory (absolute path).'); 55 | // $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.'); 56 | // $app->add($command); 57 | 58 | $actualOutput = AliasFilter::filter($inputArray); 59 | $this->assertEquals($expectOutput, $actualOutput); 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /tests/Util/FilesystemTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expected, $fs->isDescendent($child, $parent)); 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /tests/Util/OptionalOptionTest.php: -------------------------------------------------------------------------------- 1 | push(...$this->createInputOutput($inputArgv)); 46 | try { 47 | $this->assertEquals($expectValue, OptionalOption::parse(Cv::input(), ['-r', '--refresh'], 'auto', 'yes')); 48 | } 49 | finally { 50 | Cv::ioStack()->pop(); 51 | } 52 | } 53 | 54 | /** 55 | * @return array 56 | * [0 => InputInterface, 1 => OutputInterface] 57 | */ 58 | protected function createInputOutput(?array $argv = NULL): array { 59 | $input = new ArgvInput($argv); 60 | $input->setInteractive(FALSE); 61 | $output = new NullOutput(); 62 | return [$input, $output]; 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /tests/Util/ProcessTest.php: -------------------------------------------------------------------------------- 1 | assertEquals( 15 | 'ls \'whiz=1&bang=1\' \'Who\'\\\'\'s on first\'', 16 | ProcessUtil::sprintf('ls %s %s', 'whiz=1&bang=1', "Who's on first") 17 | ); 18 | 19 | $this->assertEquals( 20 | 'ls \'/home/foo bar\' /foo/bar', 21 | ProcessUtil::sprintf('ls %s %s', '/home/foo bar', '/foo/bar') 22 | ); 23 | 24 | $this->assertEquals( 25 | 'ls /foo/bar', 26 | ProcessUtil::sprintf('ls %s', '/foo/bar') 27 | ); 28 | 29 | $echoResult = ProcessUtil::runOk(Process::fromShellCommandline( 30 | ProcessUtil::sprintf('echo %s', 'whiz=1&bang=>') 31 | )); 32 | $this->assertEquals('whiz=1&bang=>', rtrim($echoResult->getOutput())); 33 | } 34 | 35 | public function testRunOk_pass() { 36 | $process = ProcessUtil::runOk(\Symfony\Component\Process\Process::fromShellCommandline("echo times were good")); 37 | $this->assertEquals("times were good", trim($process->getOutput())); 38 | } 39 | 40 | public function testRunOk_fail() { 41 | try { 42 | ProcessUtil::runOk(\Symfony\Component\Process\Process::fromShellCommandline("echo tragedy befell the software > /dev/stderr; exit 1")); 43 | $this->fail("Failed to generate expected exception"); 44 | } 45 | catch (\Civi\Cv\Exception\ProcessErrorException $e) { 46 | $this->assertEquals("tragedy befell the software", trim($e->getProcess()->getErrorOutput())); 47 | } 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | addPsr4('Civi\\Cv\\', __DIR__); 21 | -------------------------------------------------------------------------------- /tests/fixtures/org.example.cvtest/LICENSE.txt: -------------------------------------------------------------------------------- 1 | org.civicrm.cvtest 2 | Copyright (c) 2016 Tim Otten 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /tests/fixtures/org.example.cvtest/cvtest.php: -------------------------------------------------------------------------------- 1 | "yes"); 5 | } 6 | -------------------------------------------------------------------------------- /tests/fixtures/org.example.cvtest/info.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | cvtest 4 | cvtest example extension 5 | Placeholder used to test cv functionality 6 | MIT 7 | 8 | Tim Otten 9 | totten@civicrm.org 10 | 11 | 12 | http://FIXME 13 | http://FIXME 14 | http://FIXME 15 | http://www.gnu.org/licenses/agpl-3.0.html 16 | 17 | 2016-12-21 18 | 1.0 19 | alpha 20 | 21 | 4.2 22 | 23 | This is a new, undeveloped module 24 | 25 | -------------------------------------------------------------------------------- /tests/fixtures/org.example.cvtest/make.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | \n", $argv[0])); 9 | } 10 | 11 | 12 | if (file_exists($argv[1])) { 13 | unlink($argv[1]); 14 | } 15 | 16 | $zip = new ZipArchive(); 17 | if (TRUE !== $zip->open($argv[1], ZipArchive::CREATE)) { 18 | printf("Failed to open %s\n", $argv[1]); 19 | exit(1); 20 | } 21 | 22 | $zip->addEmptyDir('org.example.cvtest/'); 23 | $zip->addFile('LICENSE.txt', 'org.example.cvtest/LICENSE.txt'); 24 | $zip->addFile('cvtest.php', 'org.example.cvtest/cvtest.php'); 25 | $zip->addFile('info.xml', 'org.example.cvtest/info.xml'); 26 | 27 | $ok = $zip->close(); 28 | 29 | if ($ok) { 30 | printf("Created %s\n", $argv[1]); 31 | exit(0); 32 | } 33 | else { 34 | printf("Failed to create %s\n", $argv[1]); 35 | exit(1); 36 | } 37 | --------------------------------------------------------------------------------