diff --git a/composer.json b/composer.json index d12c0bbc..ebc117cc 100644 --- a/composer.json +++ b/composer.json @@ -57,6 +57,7 @@ "plugin search", "plugin status", "plugin check-update", + "plugin download", "plugin toggle", "plugin uninstall", "plugin update", @@ -82,6 +83,7 @@ "theme search", "theme status", "theme check-update", + "theme download", "theme update", "theme mod list", "theme auto-updates", diff --git a/extension-command.php b/extension-command.php index 3cc65c80..0df4c555 100644 --- a/extension-command.php +++ b/extension-command.php @@ -8,6 +8,8 @@ if ( file_exists( $wpcli_extension_autoloader ) ) { require_once $wpcli_extension_autoloader; } +require_once __DIR__ . '/src/Plugin_Download_Command.php'; +require_once __DIR__ . '/src/Theme_Download_Command.php'; $wpcli_extension_requires_wp_5_5 = [ 'before_invoke' => static function () { @@ -18,8 +20,22 @@ ]; WP_CLI::add_command( 'plugin', 'Plugin_Command' ); +WP_CLI::add_command( + 'plugin download', + 'Plugin_Download_Command', + [ + 'when' => 'before_wp_load', + ] +); WP_CLI::add_command( 'plugin auto-updates', 'Plugin_AutoUpdates_Command', $wpcli_extension_requires_wp_5_5 ); WP_CLI::add_command( 'theme', 'Theme_Command' ); +WP_CLI::add_command( + 'theme download', + 'Theme_Download_Command', + [ + 'when' => 'before_wp_load', + ] +); WP_CLI::add_command( 'theme auto-updates', 'Theme_AutoUpdates_Command', $wpcli_extension_requires_wp_5_5 ); WP_CLI::add_command( 'theme mod', 'Theme_Mod_Command' ); diff --git a/features/extension-download.feature b/features/extension-download.feature new file mode 100644 index 00000000..92014bc6 --- /dev/null +++ b/features/extension-download.feature @@ -0,0 +1,33 @@ +Feature: Download WordPress.org extensions without loading WordPress + + Scenario: Downloading a plugin package works before WordPress is loaded + Given a WP install + + When I run `wp plugin download debug-bar --skip-wordpress` + Then STDOUT should contain: + """ + Downloading debug-bar + """ + And STDOUT should contain: + """ + Success: Downloaded plugin package to + """ + And save STDOUT 'Success: Downloaded plugin package to (.+)' as {DOWNLOADED_PLUGIN} + And the {DOWNLOADED_PLUGIN} file should exist + And STDERR should be empty + + Scenario: Downloading a theme package works before WordPress is loaded + Given a WP install + + When I run `wp theme download twentytwelve --skip-wordpress` + Then STDOUT should contain: + """ + Downloading twentytwelve + """ + And STDOUT should contain: + """ + Success: Downloaded theme package to + """ + And save STDOUT 'Success: Downloaded theme package to (.+)' as {DOWNLOADED_THEME} + And the {DOWNLOADED_THEME} file should exist + And STDERR should be empty diff --git a/src/Plugin_Download_Command.php b/src/Plugin_Download_Command.php new file mode 100644 index 00000000..5ad4a36c --- /dev/null +++ b/src/Plugin_Download_Command.php @@ -0,0 +1,117 @@ + + * : Slug of the plugin to download. + * + * [--path=] + * : Directory to store the downloaded zip file. Defaults to the current directory. + * + * [--version=] + * : Version to download. Accepts a version number or `dev`. + * + * [--force] + * : Overwrite destination file if it already exists. + * + * [--insecure] + * : Retry download without certificate validation if TLS handshake fails. Note: This makes the request vulnerable to a MITM attack. + * + * ## EXAMPLES + * + * $ wp plugin download bbpress + * Downloading bbpress (2.5.9)... + * Success: Downloaded plugin package to /path/to/bbpress.2.5.9.zip + * + * @when before_wp_load + */ +// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound +class Plugin_Download_Command { + + /** + * Downloads a plugin zip package without loading WordPress. + * + * @param array{0: string} $args Positional arguments. + * @param array{path?: string, version?: string, force?: bool, insecure?: bool} $assoc_args Associative arguments. + */ + public function __invoke( $args, $assoc_args ) { + $slug = (string) $args[0]; + $insecure = Utils\get_flag_value( $assoc_args, 'insecure', false ); + $force = Utils\get_flag_value( $assoc_args, 'force', false ); + $requested = Utils\get_flag_value( $assoc_args, 'version', null ); + $download_dir = Utils\get_flag_value( $assoc_args, 'path', getcwd() ); + if ( '' === $slug ) { + WP_CLI::error( 'Please provide a plugin slug.' ); + } + + if ( ! is_dir( $download_dir ) ) { + if ( ! @mkdir( $download_dir, 0755, true ) ) { + WP_CLI::error( "Failed to create directory '{$download_dir}'." ); + } + } + + if ( ! is_writable( $download_dir ) ) { + WP_CLI::error( "'{$download_dir}' is not writable by current user." ); + } + + try { + $plugin_data = ( new WpOrgApi( [ 'insecure' => $insecure ] ) )->get_plugin_info( $slug ); + } catch ( Exception $exception ) { + WP_CLI::error( $exception->getMessage() ); + } + + if ( ! is_array( $plugin_data ) || empty( $plugin_data['download_link'] ) || empty( $plugin_data['version'] ) ) { + WP_CLI::error( "The '{$slug}' plugin could not be found." ); + } + + $download_url = $plugin_data['download_link']; + $version = $plugin_data['version']; + + if ( is_string( $requested ) && '' !== $requested && $requested !== $plugin_data['version'] ) { + $current_zip = basename( (string) Utils\parse_url( $download_url, PHP_URL_PATH ) ); + if ( 'dev' === $requested ) { + $download_url = str_replace( $current_zip, $slug . '.zip', $download_url ); + $version = 'Development Version'; + } else { + $download_url = str_replace( $current_zip, $slug . '.' . $requested . '.zip', $download_url ); + $version = $requested; + } + } + + $zip_name = basename( (string) Utils\parse_url( $download_url, PHP_URL_PATH ) ); + if ( '' === $zip_name ) { + $zip_name = "{$slug}.zip"; + } + + $download_file = rtrim( $download_dir, '/\\' ) . DIRECTORY_SEPARATOR . $zip_name; + + if ( ! $force && file_exists( $download_file ) ) { + WP_CLI::error( "Destination file already exists: {$download_file}" ); + } + + WP_CLI::log( "Downloading {$slug} ({$version})..." ); + + try { + Utils\http_request( + 'GET', + $download_url, + null, + [], + [ + 'filename' => $download_file, + 'insecure' => (bool) $insecure, + ] + ); + } catch ( Exception $exception ) { + WP_CLI::error( $exception->getMessage() ); + } + + WP_CLI::success( "Downloaded plugin package to {$download_file}" ); + } +} diff --git a/src/Theme_Download_Command.php b/src/Theme_Download_Command.php new file mode 100644 index 00000000..571fc41b --- /dev/null +++ b/src/Theme_Download_Command.php @@ -0,0 +1,117 @@ + + * : Slug of the theme to download. + * + * [--path=] + * : Directory to store the downloaded zip file. Defaults to the current directory. + * + * [--version=] + * : Version to download. Accepts a version number or `dev`. + * + * [--force] + * : Overwrite destination file if it already exists. + * + * [--insecure] + * : Retry download without certificate validation if TLS handshake fails. Note: This makes the request vulnerable to a MITM attack. + * + * ## EXAMPLES + * + * $ wp theme download twentytwelve + * Downloading twentytwelve (1.3)... + * Success: Downloaded theme package to /path/to/twentytwelve.1.3.zip + * + * @when before_wp_load + */ +// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound +class Theme_Download_Command { + + /** + * Downloads a theme zip package without loading WordPress. + * + * @param array{0: string} $args Positional arguments. + * @param array{path?: string, version?: string, force?: bool, insecure?: bool} $assoc_args Associative arguments. + */ + public function __invoke( $args, $assoc_args ) { + $slug = (string) $args[0]; + $insecure = Utils\get_flag_value( $assoc_args, 'insecure', false ); + $force = Utils\get_flag_value( $assoc_args, 'force', false ); + $requested = Utils\get_flag_value( $assoc_args, 'version', null ); + $download_dir = Utils\get_flag_value( $assoc_args, 'path', getcwd() ); + if ( '' === $slug ) { + WP_CLI::error( 'Please provide a theme slug.' ); + } + + if ( ! is_dir( $download_dir ) ) { + if ( ! @mkdir( $download_dir, 0755, true ) ) { + WP_CLI::error( "Failed to create directory '{$download_dir}'." ); + } + } + + if ( ! is_writable( $download_dir ) ) { + WP_CLI::error( "'{$download_dir}' is not writable by current user." ); + } + + try { + $theme_data = ( new WpOrgApi( [ 'insecure' => $insecure ] ) )->get_theme_info( $slug ); + } catch ( Exception $exception ) { + WP_CLI::error( $exception->getMessage() ); + } + + if ( ! is_array( $theme_data ) || empty( $theme_data['download_link'] ) || empty( $theme_data['version'] ) ) { + WP_CLI::error( "The '{$slug}' theme could not be found." ); + } + + $download_url = $theme_data['download_link']; + $version = $theme_data['version']; + + if ( is_string( $requested ) && '' !== $requested && $requested !== $theme_data['version'] ) { + $current_zip = basename( (string) Utils\parse_url( $download_url, PHP_URL_PATH ) ); + if ( 'dev' === $requested ) { + $download_url = str_replace( $current_zip, $slug . '.zip', $download_url ); + $version = 'Development Version'; + } else { + $download_url = str_replace( $current_zip, $slug . '.' . $requested . '.zip', $download_url ); + $version = $requested; + } + } + + $zip_name = basename( (string) Utils\parse_url( $download_url, PHP_URL_PATH ) ); + if ( '' === $zip_name ) { + $zip_name = "{$slug}.zip"; + } + + $download_file = rtrim( $download_dir, '/\\' ) . DIRECTORY_SEPARATOR . $zip_name; + + if ( ! $force && file_exists( $download_file ) ) { + WP_CLI::error( "Destination file already exists: {$download_file}" ); + } + + WP_CLI::log( "Downloading {$slug} ({$version})..." ); + + try { + Utils\http_request( + 'GET', + $download_url, + null, + [], + [ + 'filename' => $download_file, + 'insecure' => (bool) $insecure, + ] + ); + } catch ( Exception $exception ) { + WP_CLI::error( $exception->getMessage() ); + } + + WP_CLI::success( "Downloaded theme package to {$download_file}" ); + } +}