diff --git a/composer.json b/composer.json index 42396ea..9544761 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "extra": { "class": "SilverStripe\\VendorPlugin\\VendorPlugin", "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "1.3.x-dev" } }, "scripts": { @@ -27,6 +27,7 @@ }, "minimum-stability": "dev", "require": { + "composer/installers": "^1.4", "composer-plugin-api": "^1.1" }, "require-dev": { diff --git a/src/Console/VendorExposeCommand.php b/src/Console/VendorExposeCommand.php index dc22a45..4630633 100644 --- a/src/Console/VendorExposeCommand.php +++ b/src/Console/VendorExposeCommand.php @@ -6,9 +6,10 @@ use Composer\Factory; use Composer\IO\ConsoleIO; use Composer\Util\Filesystem; +use Generator; +use SilverStripe\VendorPlugin\Library; use SilverStripe\VendorPlugin\Util; use SilverStripe\VendorPlugin\VendorExposeTask; -use SilverStripe\VendorPlugin\VendorModule; use SilverStripe\VendorPlugin\VendorPlugin; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -22,7 +23,7 @@ class VendorExposeCommand extends BaseCommand public function configure() { $this->setName('vendor-expose'); - $this->setDescription('Refresh all exposed vendor module folders'); + $this->setDescription('Refresh all exposed module/theme/project folders'); $this->addArgument( 'method', InputArgument::OPTIONAL, @@ -35,16 +36,19 @@ public function execute(InputInterface $input, OutputInterface $output) { $io = new ConsoleIO($input, $output, $this->getHelperSet()); - // Check modules to expose - $modules = $this->getAllModules(); + // Check libraries to expose + $modules = $this->getAllLibraries(); if (empty($modules)) { $io->write("No modules to expose"); return; } + // Query first library for base destination + $basePublicPath = $modules[0]->getBasePublicPath(); + // Expose all modules $method = $input->getArgument('method'); - $task = new VendorExposeTask($this->getProjectPath(), new Filesystem(), VendorModule::DEFAULT_TARGET); + $task = new VendorExposeTask($this->getProjectPath(), new Filesystem(), $basePublicPath); $task->process($io, $modules, $method); // Success @@ -52,33 +56,98 @@ public function execute(InputInterface $input, OutputInterface $output) } /** - * Find all modules + * Get all libraries * - * @return VendorModule[] + * @return Library[] */ - protected function getAllModules() + protected function getAllLibraries() { $modules = []; $basePath = $this->getProjectPath(); - $search = Util::joinPaths($basePath, 'vendor', '*', '*'); - foreach (glob($search, GLOB_ONLYDIR) as $modulePath) { + + // Get all modules + foreach ($this->getModulePaths() as $modulePath) { // Filter by non-composer folders $composerPath = Util::joinPaths($modulePath, 'composer.json'); if (!file_exists($composerPath)) { continue; } - // Build module - $name = basename($modulePath); - $vendor = basename(dirname($modulePath)); - $module = new VendorModule($basePath, "{$vendor}/{$name}"); - // Check if this module has folders to expose - if ($module->getExposedFolders()) { - $modules[] = $module; + + // Ensure this library should be exposed, and has at least one folder + $module = new Library($basePath, $modulePath); + if (!$module->requiresExpose() || !$module->getExposedFolders()) { + continue; } + + // Save this module + $modules[] = $module; } return $modules; } + /** + * Find all modules + * + * @deprecated 1.3..2.0 + * @return Library[] + */ + protected function getAllModules() + { + return $this->getAllLibraries(); + } + + /** + * Search all paths that could contain a module / theme + * + * @return Generator + */ + protected function getModulePaths() + { + // Project root is always returned + $basePath = $this->getProjectPath(); + yield $basePath; + + // Get vendor modules + $search = Util::joinPaths($basePath, 'vendor', '*', '*'); + foreach (glob($search, GLOB_ONLYDIR) as $modulePath) { + if ($this->isPathModule($modulePath)) { + yield $modulePath; + } + } + + // Check if public/ folder exists + $publicExists = is_dir(Util::joinPaths($basePath, Library::PUBLIC_PATH)); + if (!$publicExists) { + return; + } + + // Search all base folders / modules + $search = Util::joinPaths($basePath, '*'); + foreach (glob($search, GLOB_ONLYDIR) as $modulePath) { + if ($this->isPathModule($modulePath)) { + yield $modulePath; + } + } + + // Check all themes + $search = Util::joinPaths($basePath, 'themes', '*'); + foreach (glob($search, GLOB_ONLYDIR) as $themePath) { + yield $themePath; + } + } + + /** + * Check if the given path is a silverstripe module + * + * @param string $path + * @return bool + */ + protected function isPathModule($path) + { + return file_exists(Util::joinPaths($path, '_config')) + || file_exists(Util::joinPaths($path, '_config.php')); + } + /** * @return string */ diff --git a/src/Library.php b/src/Library.php new file mode 100644 index 0000000..14bd23c --- /dev/null +++ b/src/Library.php @@ -0,0 +1,288 @@ +basePath = $basePath; + $this->path = $libraryPath; + $this->name = $name; + } + + /** + * Module name + * + * @var string + */ + protected $name = null; + + /** + * Get module name + * + * @return string + */ + public function getName() + { + if ($this->name) { + return $this->name; + } + // Get from composer + $json = $this->getJson(); + if (isset($json['name'])) { + $this->name = $json['name']; + } + return $this->name; + } + + /** + * Get type of library + * + * @return string + */ + public function getType() + { + // Get from composer + $json = $this->getJson(); + if (isset($json['type'])) { + return $json['type']; + } + return 'module'; + } + + /** + * Get path to base project for this module + * + * @return string Path with no trailing slash E.g. /var/www/ + */ + public function getBasePath() + { + return $this->basePath; + } + + /** + * Get base path to expose all libraries to + * + * @return string Path with no trailing slash E.g. /var/www/public/resources + */ + public function getBasePublicPath() + { + $projectPath = $this->getBasePath(); + $publicPath = $this->publicPathExists() + ? Util::joinPaths($projectPath, self::PUBLIC_PATH, self::RESOURCES_PATH) + : Util::joinPaths($projectPath, self::RESOURCES_PATH); + return $publicPath; + } + + /** + * Get path for this module + * + * @return string Path with no trailing slash E.g. /var/www/vendor/silverstripe/module + */ + public function getPath() + { + return $this->path; + } + + /** + * Get path relative to base dir. + * If module path is base this will be empty string + * + * @return string Path with trimmed slashes. E.g. vendor/silverstripe/module. + * This will be empty for the base project. + */ + public function getRelativePath() + { + return trim(substr($this->path, strlen($this->basePath)), self::TRIM_CHARS); + } + + /** + * Get base path to map resources for this module + * + * @return string Path with trimmed slashes. E.g. /var/www/public/resources/vendor/silverstripe/module + */ + public function getPublicPath() + { + $relativePath = $this->getRelativePath(); + + // 4.0 compatibility: If there is no public folder, and this is a vendor path, + // remove the leading `vendor` from the destination + if (!$this->publicPathExists() && $this->installedIntoVendor()) { + $relativePath = substr($relativePath, strlen('vendor/')); + } + + return Util::joinPaths($this->getBasePublicPath(), $relativePath); + } + + /** + * Cache of composer.json content + * + * @var array + */ + protected $json = []; + + /** + * Get json content for this module from composer.json + * + * @return array + */ + protected function getJson() + { + if ($this->json) { + return $this->json; + } + $composer = Util::joinPaths($this->getPath(), 'composer.json'); + $file = new JsonFile($composer); + $this->json = $file->read(); + return $this->json; + } + + /** + * Determine if this module should be exposed. + * Note: If not using public folders, only vendor modules need to be exposed + * + * @return bool + */ + public function requiresExpose() + { + // Don't expose if no folders configured + if (!$this->getExposedFolders()) { + return false; + } + + // Expose if either public root exists, or vendor module + return $this->publicPathExists() || $this->installedIntoVendor(); + } + + /** + * Expose all web accessible paths for this module + * + * @param ExposeMethod $method + */ + public function exposePaths(ExposeMethod $method) + { + // No-op if exposure not necessary for this configuration + if (!$this->requiresExpose()) { + return; + } + $folders = $this->getExposedFolders(); + $sourcePath = $this->getPath(); + $targetPath = $this->getPublicPath(); + foreach ($folders as $folder) { + // Get paths for this folder and delegate to expose method + $folderSourcePath = Util::joinPaths($sourcePath, $folder); + $folderTargetPath = Util::joinPaths($targetPath, $folder); + $method->exposeDirectory($folderSourcePath, $folderTargetPath); + } + } + + /** + * Get name of all folders to expose (relative to module root) + * + * @return array + */ + public function getExposedFolders() + { + $data = $this->getJson(); + + // Only expose if correct type + if (empty($data['type']) || !preg_match(VendorPlugin::MODULE_FILTER, $data['type'])) { + return []; + } + + // Get all dirs to expose + if (empty($data['extra']['expose'])) { + return []; + } + $expose = $data['extra']['expose']; + + // Validate all paths are safe + foreach ($expose as $exposeFolder) { + if (!$this->validateFolder($exposeFolder)) { + throw new LogicException("Invalid module folder " . $exposeFolder); + } + } + return $expose; + } + + /** + * Validate the given folder is allowed + * + * @param string $exposeFolder Relative folder name to check + * @return bool + */ + protected function validateFolder($exposeFolder) + { + if (strstr($exposeFolder, '.')) { + return false; + } + if (strpos($exposeFolder, '/') === 0) { + return false; + } + if (strpos($exposeFolder, '\\') === 0) { + return false; + } + return true; + } + + /** + * Determin eif the public folder exists + * + * @return bool + */ + public function publicPathExists() + { + return is_dir(Util::joinPaths($this->getBasePath(), self::PUBLIC_PATH)); + } + + /** + * Check if this module is installed in vendor + * + * @return bool + */ + protected function installedIntoVendor() + { + return preg_match('#^vendor[/\\\\]#', $this->getRelativePath()); + } +} diff --git a/src/Util.php b/src/Util.php index 28de238..6f47e8a 100644 --- a/src/Util.php +++ b/src/Util.php @@ -13,6 +13,7 @@ class Util public static function joinPaths(...$parts) { $combined = null; + $parts = array_filter($parts); array_walk_recursive($parts, function ($part) use (&$combined) { // Normalise path $part = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $part); diff --git a/src/VendorExposeTask.php b/src/VendorExposeTask.php index 5683056..8f88144 100644 --- a/src/VendorExposeTask.php +++ b/src/VendorExposeTask.php @@ -32,37 +32,37 @@ class VendorExposeTask protected $filesystem; /** - * Name of resources folder + * Path to expose to * * @var string */ - protected $resourcesFolder; + protected $resourcesPath; /** * Construct task for the given base folder * * @param string $basePath * @param Filesystem $filesystem - * @param string $resourcesFolder Base name of 'resources' folder + * @param string $publicPath Public path to expose to */ - public function __construct($basePath, Filesystem $filesystem, $resourcesFolder) + public function __construct($basePath, Filesystem $filesystem, $publicPath) { $this->basePath = $basePath; $this->filesystem = $filesystem; - $this->resourcesFolder = $resourcesFolder; + $this->resourcesPath = $publicPath; } /** * Expose all modules with the given method * * @param IOInterface $io - * @param VendorModule[] $modules + * @param Library[] $libraries * @param string $methodKey Method key, or null to auto-detect from environment */ - public function process(IOInterface $io, array $modules, $methodKey = null) + public function process(IOInterface $io, array $libraries, $methodKey = null) { // No-op - if (empty($modules)) { + if (empty($libraries)) { return; } @@ -76,10 +76,15 @@ public function process(IOInterface $io, array $modules, $methodKey = null) $method = $this->getMethod($methodKey); // Update all modules - foreach ($modules as $module) { + foreach ($libraries as $module) { + // Skip this module if no exposure required + if (!$module->requiresExpose()) { + continue; + } $name = $module->getName(); + $type = $module->getType(); $io->write( - "Exposing web directories for module {$name} with method {$methodKey}:" + "Exposing web directories for {$type} {$name} with method {$methodKey}:" ); foreach ($module->getExposedFolders() as $folder) { $io->write(" - $folder"); @@ -149,7 +154,6 @@ protected function getMethod($key) } } - /** * Get 'key' of method to use * @@ -204,9 +208,6 @@ protected function getMethodFilePath() */ protected function getResourcesPath() { - return Util::joinPaths( - $this->basePath, - $this->resourcesFolder - ); + return $this->resourcesPath; } } diff --git a/src/VendorModule.php b/src/VendorModule.php index bca85a6..6e4b2e6 100644 --- a/src/VendorModule.php +++ b/src/VendorModule.php @@ -2,38 +2,22 @@ namespace SilverStripe\VendorPlugin; -use Composer\Json\JsonFile; -use LogicException; -use SilverStripe\VendorPlugin\Methods\ExposeMethod; - /** * Represents a module in the vendor folder + * + * @deprecated 1.3..2.0 Use Library instead */ -class VendorModule +class VendorModule extends Library { - /** - * Default replacement folder for 'vendor' - */ - const DEFAULT_TARGET = 'resources'; - /** * Default source folder */ const DEFAULT_SOURCE = 'vendor'; /** - * Project root - * - * @var string - */ - protected $basePath = null; - - /** - * Module name - * - * @var string + * Default replacement folder for 'vendor' */ - protected $name = null; + const DEFAULT_TARGET = 'resources'; /** * Build a vendor module library @@ -43,112 +27,27 @@ class VendorModule */ public function __construct($basePath, $name) { - $this->basePath = $basePath; - $this->name = $name; - } - - /** - * Get module name - * - * @return string - */ - public function getName() - { - return $this->name; + $path = Util::joinPaths( + $basePath, + static::DEFAULT_SOURCE, + explode('/', $name) + ); + parent::__construct($basePath, $path, $name); } /** * Get full path to the root install for this project * + * @deprecated 1.3..2.0 use getPath() instead * @param string $base Rewrite root (or 'vendor' for actual module path) * @return string Path for this module */ public function getModulePath($base = self::DEFAULT_SOURCE) { - return Util::joinPaths( - $this->basePath, - $base, - explode('/', $this->name) - ); - } - - /** - * Get json content for this module from composer.json - * - * @return array - */ - protected function getJson() - { - $composer = Util::joinPaths($this->getModulePath(), 'composer.json'); - $file = new JsonFile($composer); - return $file->read(); - } - - /** - * Expose all web accessible paths for this module - * - * @param ExposeMethod $method - * @param string $target Replacement target for 'vendor' prefix to rewrite to. Defaults to 'resources' - */ - public function exposePaths(ExposeMethod $method, $target = self::DEFAULT_TARGET) - { - $folders = $this->getExposedFolders(); - $sourcePath = $this->getModulePath(self::DEFAULT_SOURCE); - $targetPath = $this->getModulePath($target); - foreach ($folders as $folder) { - // Get paths for this folder and delegate to expose method - $folderSourcePath = Util::joinPaths($sourcePath, $folder); - $folderTargetPath = Util::joinPaths($targetPath, $folder); - $method->exposeDirectory($folderSourcePath, $folderTargetPath); - } - } - - /** - * Get name of all folders to expose (relative to module root) - * - * @return array - */ - public function getExposedFolders() - { - $data = $this->getJson(); - - // Only expose if correct type - if (empty($data['type']) || $data['type'] !== VendorPlugin::MODULE_TYPE) { - return []; - } - - // Get all dirs to expose - if (empty($data['extra']['expose'])) { - return []; - } - $expose = $data['extra']['expose']; - - // Validate all paths are safe - foreach ($expose as $exposeFolder) { - if (!$this->validateFolder($exposeFolder)) { - throw new LogicException("Invalid module folder " . $exposeFolder); - } - } - return $expose; - } - - /** - * Validate the given folder is allowed - * - * @param string $exposeFolder Relative folder name to check - * @return bool - */ - protected function validateFolder($exposeFolder) - { - if (strstr($exposeFolder, '.')) { - return false; - } - if (strpos($exposeFolder, '/') === 0) { - return false; - } - if (strpos($exposeFolder, '\\') === 0) { - return false; + if ($base === self::DEFAULT_TARGET) { + return $this->getPublicPath(); + } else { + return $this->getPath(); } - return true; } } diff --git a/src/VendorPlugin.php b/src/VendorPlugin.php index e48969c..2e34d68 100644 --- a/src/VendorPlugin.php +++ b/src/VendorPlugin.php @@ -9,11 +9,13 @@ use Composer\EventDispatcher\EventSubscriberInterface; use Composer\Factory; use Composer\Installer\PackageEvent; +use Composer\Installers\Installer; use Composer\IO\IOInterface; use Composer\Package\PackageInterface; use Composer\Plugin\Capability\CommandProvider; use Composer\Plugin\Capable; use Composer\Plugin\PluginInterface; +use Composer\Script\Event; use Composer\Util\Filesystem; use SilverStripe\VendorPlugin\Console\VendorCommandProvider; @@ -23,10 +25,17 @@ class VendorPlugin implements PluginInterface, EventSubscriberInterface, Capable { /** - * Module type to match + * Default module type + * + * @deprecated 1.3..2.0 Use MODULE_FILTER instead */ const MODULE_TYPE = 'silverstripe-vendormodule'; + /** + * Filter for matching library types to expose + */ + const MODULE_FILTER = '/^silverstripe\-(\w+)$/'; + /** * Method env var to query */ @@ -78,29 +87,43 @@ public static function getSubscribedEvents() 'post-package-update' => 'installPackage', 'post-package-install' => 'installPackage', 'pre-package-uninstall' => 'uninstallPackage', + 'post-install-cmd' => 'installRootPackage', + 'post-update-cmd' => 'installRootPackage', ]; } /** * Get vendor module instance for this event * + * @deprecated 1.3..2.0 * @param PackageEvent $event - * @return VendorModule + * @return Library|null */ protected function getVendorModule(PackageEvent $event) + { + return $this->getLibrary($event); + } + + /** + * Gets library being installed + * + * @param PackageEvent $event + * @return Library|null + */ + public function getLibrary(PackageEvent $event) { // Ensure package is the valid type $package = $this->getOperationPackage($event); - if (!$package || $package->getType() !== self::MODULE_TYPE) { + if (!$package || !preg_match(self::MODULE_FILTER, $package->getType())) { return null; } - // Find project path - $projectPath = $this->getProjectPath(); - $name = $package->getName(); + // Get appropriate installer and query install path + $installer = $event->getComposer()->getInstallationManager()->getInstaller($package->getType()); + $path = $installer->getInstallPath($package); // Build module - return new VendorModule($projectPath, $name); + return new Library($this->getProjectPath(), $path); } /** @@ -110,18 +133,34 @@ protected function getVendorModule(PackageEvent $event) */ public function installPackage(PackageEvent $event) { - // Ensure module exists and has any folders to expose - $module = $this->getVendorModule($event); - if (!$module || !$module->getExposedFolders()) { + // Ensure module exists and requires exposure + $library = $this->getLibrary($event); + if (!$library) { return; } - // Run with task - $task = new VendorExposeTask($this->getProjectPath(), $this->filesystem, VendorModule::DEFAULT_TARGET); - $task->process($event->getIO(), [$module]); + // Install found library + $this->installLibrary($event->getIO(), $library); } /** + * Install resources from the root package + * + * @param Event $event + */ + public function installRootPackage(Event $event) + { + // Build library in base path + $basePath = $this->getProjectPath(); + $library = new Library($basePath, $basePath); + + // Pass to library installer + $this->installLibrary($event->getIO(), $library); + } + + /** + * Get base path to project + * * @return string */ protected function getProjectPath() @@ -136,27 +175,27 @@ protected function getProjectPath() */ public function uninstallPackage(PackageEvent $event) { - // Ensure package is the valid type - $module = $this->getVendorModule($event); - if (!$module) { + // Check if library exists and exposes any directories + $library = $this->getLibrary($event); + if (!$library || !$library->requiresExpose()) { return; } // Check path to remove - $target = $module->getModulePath(VendorModule::DEFAULT_TARGET); + $target = $library->getPublicPath(); if (!is_dir($target)) { return; } // Remove directory - $name = $module->getName(); + $name = $library->getName(); $event->getIO()->write("Removing web directories for module {$name}:"); $this->filesystem->removeDirectory($target); // Cleanup empty vendor dir if this is the last module - $vendorTarget = dirname($target); - if ($this->filesystem->isDirEmpty($vendorTarget)) { - $this->filesystem->removeDirectory($vendorTarget); + $targetParent = dirname($target); + if ($this->filesystem->isDirEmpty($targetParent)) { + $this->filesystem->removeDirectory($targetParent); } } @@ -187,4 +226,25 @@ public function getCapabilities() CommandProvider::class => VendorCommandProvider::class ]; } + + /** + * Expose the given Library object + * + * @param IOInterface $IO + * @param Library $library + */ + protected function installLibrary(IOInterface $IO, Library $library) + { + if (!$library || !$library->requiresExpose()) { + return; + } + + // Create exposure task + $task = new VendorExposeTask( + $this->getProjectPath(), + $this->filesystem, + $library->getBasePublicPath() + ); + $task->process($IO, [$library]); + } }