diff --git a/composer.json b/composer.json index 4827a65c..6cbe2c7c 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,7 @@ "plugin delete", "plugin get", "plugin install", + "plugin install-dependencies", "plugin is-installed", "plugin list", "plugin path", diff --git a/features/plugin-dependencies.feature b/features/plugin-dependencies.feature new file mode 100644 index 00000000..7b4b654b --- /dev/null +++ b/features/plugin-dependencies.feature @@ -0,0 +1,193 @@ +Feature: Plugin dependencies support + + Background: + Given an empty cache + + @less-than-wp-6.5 + Scenario: Install plugin with dependencies using --with-dependencies flag + Given a WP install + + When I try `wp plugin install --with-dependencies bp-classic` + Then STDERR should contain: + """ + Installing plugins with dependencies requires WordPress 6.5 or greater. + """ + + @require-wp-6.5 + Scenario: Install plugin with dependencies using --with-dependencies flag + Given a WP install + + When I run `wp plugin install --with-dependencies bp-classic` + Then STDOUT should contain: + """ + Installing BuddyPress + """ + And STDOUT should contain: + """ + Installing BP Classic + """ + And STDOUT should contain: + """ + Success: Installed 2 of 2 plugins. + """ + + When I run `wp plugin list --fields=name,status --format=csv` + Then STDOUT should contain: + """ + buddypress,inactive + """ + And STDOUT should contain: + """ + bp-classic,inactive + """ + + @less-than-wp-6.5 + Scenario: Install dependencies of an installed plugin + Given a WP install + + When I try `wp plugin install-dependencies akismet` + Then STDERR should contain: + """ + Installing plugin dependencies requires WordPress 6.5 or greater. + """ + + @require-wp-6.5 + Scenario: Install dependencies of an installed plugin + Given a WP install + + # Create a test plugin with dependencies + And a wp-content/plugins/test-plugin/test-plugin.php file: + """ + install_with_dependencies( $args, $assoc_args ); + } else { + parent::install( $args, $assoc_args ); + } + } + + /** + * Installs plugins with their dependencies. + * + * @param array $args Plugin slugs to install. + * @param array $assoc_args Associative arguments. + */ + private function install_with_dependencies( $args, $assoc_args ) { + $all_to_install = []; + $installed_tracker = []; + + // Remove with-dependencies from assoc_args to avoid infinite recursion + unset( $assoc_args['with-dependencies'] ); + + // Collect all plugins and their dependencies + foreach ( $args as $slug ) { + $this->collect_dependencies( $slug, $all_to_install, $installed_tracker ); + } + + if ( empty( $all_to_install ) ) { + WP_CLI::success( 'No plugins to install.' ); + return; + } + + // Install all collected plugins + parent::install( $all_to_install, $assoc_args ); + } + + /** + * Recursively collects all dependencies for a plugin. + * + * @param string $slug Plugin slug. + * @param array &$all_to_install Reference to array of all plugins to install. + * @param array &$installed_tracker Reference to array tracking what we've already processed. + */ + private function collect_dependencies( $slug, &$all_to_install, &$installed_tracker ) { + // Skip if already processed + if ( isset( $installed_tracker[ $slug ] ) ) { + return; + } + + $installed_tracker[ $slug ] = true; + + // Skip if it's a URL or zip file (can't get dependencies for those) + $is_remote = false !== strpos( $slug, '://' ); + if ( $is_remote || ( pathinfo( $slug, PATHINFO_EXTENSION ) === 'zip' && is_file( $slug ) ) ) { + $all_to_install[] = $slug; + return; + } + + // Get plugin dependencies from WordPress.org API + $dependencies = $this->get_plugin_dependencies( $slug ); + + // Recursively install dependencies first + if ( ! empty( $dependencies ) ) { + foreach ( $dependencies as $dependency_slug ) { + $this->collect_dependencies( $dependency_slug, $all_to_install, $installed_tracker ); + } + } + + // Add this plugin to the install list + $all_to_install[] = $slug; + } + + /** + * Gets the dependencies for a plugin. + * + * @param string $slug Plugin slug. + * @return array Array of dependency slugs. + */ + private function get_plugin_dependencies( $slug ) { + // Find the plugin file for this slug + $plugins = get_plugins(); + foreach ( $plugins as $plugin_file => $plugin_data ) { + $plugin_slug = dirname( $plugin_file ); + if ( '.' === $plugin_slug ) { + $plugin_slug = basename( $plugin_file, '.php' ); + } + + if ( $plugin_slug === $slug ) { + WP_Plugin_Dependencies::initialize(); + return WP_Plugin_Dependencies::get_dependencies( $plugin_file ); + } + } + + // Fallback to WordPress.org API for plugins not yet installed + $api = plugins_api( 'plugin_information', array( 'slug' => $slug ) ); + + if ( is_wp_error( $api ) ) { + WP_CLI::warning( "Could not fetch information for plugin '$slug': " . $api->get_error_message() ); + return []; + } + + // Check if requires_plugins field exists and is not empty + if ( ! empty( $api->requires_plugins ) && is_array( $api->requires_plugins ) ) { + return $api->requires_plugins; + } + + return []; } /** @@ -1377,6 +1498,68 @@ public function is_active( $args, $assoc_args ) { $this->check_active( $plugin->file, $network_wide ) ? WP_CLI::halt( 0 ) : WP_CLI::halt( 1 ); } + /** + * Installs all dependencies of an installed plugin. + * + * This command is useful when you have a plugin installed that depends on other plugins, + * and you want to install those dependencies without activating the main plugin. + * + * ## OPTIONS + * + * + * : The installed plugin to get dependencies for. + * + * [--activate] + * : If set, dependencies will be activated immediately after install. + * + * [--activate-network] + * : If set, dependencies will be network activated immediately after install. + * + * [--force] + * : If set, the command will overwrite any installed version of the plugin, without prompting + * for confirmation. + * + * ## EXAMPLES + * + * # Install all dependencies of an installed plugin + * $ wp plugin install-dependencies my-plugin + * Installing dependency: required-plugin-1 (1.2.3) + * Plugin installed successfully. + * Installing dependency: required-plugin-2 (2.0.0) + * Plugin installed successfully. + * Success: Installed 2 dependencies. + * + * @subcommand install-dependencies + */ + public function install_dependencies( $args, $assoc_args ) { + if ( WP_CLI\Utils\wp_version_compare( '6.5', '<' ) ) { + WP_CLI::error( 'Installing plugin dependencies requires WordPress 6.5 or greater.' ); + } + + $plugin = $this->fetcher->get_check( $args[0] ); + $file = $plugin->file; + + $dependencies = []; + + WP_Plugin_Dependencies::initialize(); + $dependencies = WP_Plugin_Dependencies::get_dependencies( $file ); + + if ( empty( $dependencies ) ) { + WP_CLI::success( "Plugin '{$args[0]}' has no dependencies." ); + return; + } + + WP_CLI::log( sprintf( "Installing %d %s for '%s'...", count( $dependencies ), Utils\pluralize( 'dependency', count( $dependencies ) ), $args[0] ) ); + + // Remove with-dependencies flag to avoid recursive dependency resolution + unset( $assoc_args['with-dependencies'] ); + + // Install dependencies + $this->chained_command = true; + $this->install( $dependencies, $assoc_args ); + $this->chained_command = false; + } + /** * Deletes plugin files without deactivating or uninstalling. *