<?php
/**
* CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
* @link http://cakephp.org CakePHP(tm) Project
* @since 0.1.0
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
namespace Bake\Shell\Task;
use Cake\Core\App;
use Cake\Core\Configure;
use Cake\Core\Plugin;
use Cake\Filesystem\File;
use Cake\Filesystem\Folder;
use Cake\Utility\Inflector;
/**
* The Plugin Task handles creating an empty plugin, ready to be used
*
* @property \Bake\Shell\Task\BakeTemplateTask $BakeTemplate
*/
class PluginTask extends BakeTask
{
/**
* Tasks this task uses.
*
* @var array
*/
public $tasks = [
'Bake.BakeTemplate',
];
/**
* Plugin path.
*
* @var string
*/
public $path;
/**
* initialize
*
* @return void
*/
public function initialize()
{
$this->path = current(App::path('Plugin'));
}
/**
* Execution method always used for tasks
*
* @param string|null $name The name of the plugin to bake.
* @return null|bool
*/
public function main($name = null)
{
if (empty($name)) {
$this->err('<error>You must provide a plugin name in CamelCase format.</error>');
$this->err('To make an "MyExample" plugin, run <info>`cake bake plugin MyExample`</info>.');
return false;
}
$parts = explode('/', $name);
$plugin = implode('/', array_map([$this, '_camelize'], $parts));
$pluginPath = $this->_pluginPath($plugin);
if (is_dir($pluginPath)) {
$this->out(sprintf('Plugin: %s already exists, no action taken', $plugin));
$this->out(sprintf('Path: %s', $pluginPath));
return false;
}
if (!$this->bake($plugin)) {
$this->abort(sprintf("An error occurred trying to bake: %s in %s", $plugin, $this->path . $plugin));
}
}
/**
* Bake the plugin's contents
*
* Also update the autoloader and the root composer.json file if it can be found
*
* @param string $plugin Name of the plugin in CamelCased format
* @return bool|null
*/
public function bake($plugin)
{
$pathOptions = App::path('Plugin');
if (count($pathOptions) > 1) {
$this->findPath($pathOptions);
}
$this->out(sprintf("<info>Plugin Name:</info> %s", $plugin));
$this->out(sprintf("<info>Plugin Directory:</info> %s", $this->path . $plugin));
$this->hr();
$looksGood = $this->in('Look okay?', ['y', 'n', 'q'], 'y');
if (strtolower($looksGood) !== 'y') {
return null;
}
$this->_generateFiles($plugin, $this->path);
$this->_modifyAutoloader($plugin, $this->path);
$this->_modifyApplication($plugin);
$this->hr();
$this->out(sprintf('<success>Created:</success> %s in %s', $plugin, $this->path . $plugin), 2);
$emptyFile = $this->path . 'empty';
$this->_deleteEmptyFile($emptyFile);
return true;
}
/**
* Modify the application class
*
* @param string $plugin Name of plugin
* the plugin
* @return void
*/
protected function _modifyApplication($plugin)
{
$application = new File(ROOT . DS . 'src' . DS . 'Application.php', false);
if (!$application->exists()) {
$this->err('<warning>Could not update application Application.php file, as it could not be found.</warning>');
return;
}
$this->dispatchShell('plugin', 'load', $plugin);
}
/**
* Generate all files for a plugin
*
* Find the first path which contains `src/Template/Bake/Plugin` that contains
* something, and use that as the template to recursively render a plugin's
* contents. Allows the creation of a bake them containing a `Plugin` folder
* to provide customized bake output for plugins.
*
* @param string $pluginName the CamelCase name of the plugin
* @param string $path the path to the plugins dir (the containing folder)
* @return void
*/
protected function _generateFiles($pluginName, $path)
{
$namespace = str_replace('/', '\\', $pluginName);
$baseNamespace = Configure::read('App.namespace');
$name = $pluginName;
$vendor = 'your-name-here';
if (strpos($pluginName, '/') !== false) {
list($vendor, $name) = explode('/', $pluginName);
}
$package = $vendor . '/' . $name;
$this->BakeTemplate->set([
'package' => $package,
'namespace' => $namespace,
'baseNamespace' => $baseNamespace,
'plugin' => $pluginName,
'routePath' => Inflector::dasherize($pluginName),
'path' => $path,
'root' => ROOT,
]);
$root = $path . $pluginName . DS;
$paths = [];
if (!empty($this->params['theme'])) {
$paths[] = Plugin::path($this->params['theme']) . 'src/Template/';
}
$paths = array_merge($paths, Configure::read('App.paths.templates'));
$paths[] = Plugin::path('Bake') . 'src/Template/';
do {
$templatesPath = array_shift($paths) . 'Bake/Plugin';
$templatesDir = new Folder($templatesPath);
$templates = $templatesDir->findRecursive('.*\.(twig|ctp)');
} while (!$templates);
sort($templates);
foreach ($templates as $template) {
$template = substr($template, strrpos($template, 'Plugin' . DIRECTORY_SEPARATOR) + 7, -4);
$template = rtrim($template, '.');
$this->_generateFile($template, $root);
}
}
/**
* Generate a file
*
* @param string $template The template to render
* @param string $root The path to the plugin's root
* @return void
*/
protected function _generateFile($template, $root)
{
$this->out(sprintf('Generating %s file...', $template));
$out = $this->BakeTemplate->generate('Plugin/' . $template);
$this->createFile($root . $template, $out);
}
/**
* Modifies App's composer.json to include the plugin and tries to call
* composer dump-autoload to refresh the autoloader cache
*
* @param string $plugin Name of plugin
* @param string $path The path to save the phpunit.xml file to.
* @return bool True if composer could be modified correctly
*/
protected function _modifyAutoloader($plugin, $path)
{
$file = $this->_rootComposerFilePath();
if (!file_exists($file)) {
$this->out(sprintf('<info>Main composer file %s not found</info>', $file));
return false;
}
$autoloadPath = str_replace(ROOT, '.', $this->path);
$autoloadPath = str_replace('\\', '/', $autoloadPath);
$namespace = str_replace('/', '\\', $plugin);
$config = json_decode(file_get_contents($file), true);
$config['autoload']['psr-4'][$namespace . '\\'] = $autoloadPath . $plugin . "/src/";
$config['autoload-dev']['psr-4'][$namespace . '\\Test\\'] = $autoloadPath . $plugin . "/tests/";
$this->out('<info>Modifying composer autoloader</info>');
$out = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
$this->createFile($file, $out);
$composer = $this->findComposer();
if (!$composer) {
$this->abort('Could not locate composer. Add composer to your PATH, or use the --composer option.');
return false;
}
try {
$cwd = getcwd();
// Windows makes running multiple commands at once hard.
chdir(dirname($this->_rootComposerFilePath()));
$command = 'php ' . escapeshellarg($composer) . ' dump-autoload';
$this->callProcess($command);
chdir($cwd);
} catch (\RuntimeException $e) {
$error = $e->getMessage();
$this->abort(sprintf('Could not run `composer dump-autoload`: %s', $error));
return false;
}
return true;
}
/**
* The path to the main application's composer file
*
* This is a test isolation wrapper
*
* @return string the abs file path
*/
protected function _rootComposerFilePath()
{
return ROOT . DS . 'composer.json';
}
/**
* find and change $this->path to the user selection
*
* @param array $pathOptions The list of paths to look in.
* @return void
*/
public function findPath(array $pathOptions)
{
$valid = false;
foreach ($pathOptions as $i => $path) {
if (!is_dir($path)) {
unset($pathOptions[$i]);
}
}
$pathOptions = array_values($pathOptions);
$max = count($pathOptions);
if ($max === 0) {
$this->err('No valid plugin paths found! Please configure a plugin path that exists.');
throw new \RuntimeException();
}
if ($max === 1) {
$this->path = $pathOptions[0];
return;
}
$choice = null;
while (!$valid) {
foreach ($pathOptions as $i => $option) {
$this->out($i + 1 . '. ' . $option);
}
$prompt = 'Choose a plugin path from the paths above.';
$choice = (int)$this->in($prompt, null, '1');
if ($choice > 0 && $choice <= $max) {
$valid = true;
}
}
$this->path = $pathOptions[$choice - 1];
}
/**
* Gets the option parser instance and configures it.
*
* @return \Cake\Console\ConsoleOptionParser
*/
public function getOptionParser()
{
$parser = parent::getOptionParser();
$parser->setDescription(
'Create the directory structure, AppController class and testing setup for a new plugin. ' .
'Can create plugins in any of your bootstrapped plugin paths.'
)->addArgument('name', [
'help' => 'CamelCased name of the plugin to create.',
])->addOption('composer', [
'default' => ROOT . DS . 'composer.phar',
'help' => 'The path to the composer executable.',
])->removeOption('plugin');
return $parser;
}
/**
* Uses either the CLI option or looks in $PATH and cwd for composer.
*
* @return string|bool Either the path to composer or false if it cannot be found.
*/
public function findComposer()
{
if (!empty($this->params['composer'])) {
$path = $this->params['composer'];
if (file_exists($path)) {
return $path;
}
}
$composer = false;
$path = env('PATH');
if (!empty($path)) {
$paths = explode(PATH_SEPARATOR, $path);
$composer = $this->_searchPath($paths);
}
return $composer;
}
/**
* Search the $PATH for composer.
*
* @param array $path The paths to search.
* @return string|bool
*/
protected function _searchPath($path)
{
$composer = ['composer.phar', 'composer'];
foreach ($path as $dir) {
foreach ($composer as $cmd) {
if (is_file($dir . DS . $cmd)) {
$this->_io->verbose('Found composer executable in ' . $dir);
return $dir . DS . $cmd;
}
}
}
return false;
}
}