From da788238ea15b047a4450d547772b696543733b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 09:54:33 +0000 Subject: [PATCH 1/4] Initial plan From 298a82d8a85eb57deeadefeac4bfbdd9c6283ce2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 10:46:14 +0000 Subject: [PATCH 2/4] Add before-WP-load plugin and theme download commands Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- composer.json | 2 + extension-command.php | 16 ++++ features/extension-download.feature | 33 ++++++++ src/Plugin_Download_Command.php | 117 ++++++++++++++++++++++++++++ src/Theme_Download_Command.php | 117 ++++++++++++++++++++++++++++ 5 files changed, 285 insertions(+) create mode 100644 features/extension-download.feature create mode 100644 src/Plugin_Download_Command.php create mode 100644 src/Theme_Download_Command.php 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}" ); + } +} From 5603469e8d4c4a5ac7cc64ae5b0942e7afe1f404 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 11:04:04 +0000 Subject: [PATCH 3/4] Fix review comments: slug validation, version URL validation, HTTP status checks, and feature tests - Move empty-slug check to top of __invoke() before any filesystem ops - Add HEAD request validation for specific version URLs before downloading - Check HTTP response status code after download and clean up on failure - Update feature file: use 'Given an empty directory' instead of WP install, add scenarios for --version, --path, --force, and error paths - Add wp-cli's phpstan scan-files.php to resolve Requests_Response type Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/extension-download.feature | 142 +++++++++++++++++++++++++++- phpstan.neon.dist | 1 + src/Plugin_Download_Command.php | 34 ++++++- src/Theme_Download_Command.php | 34 ++++++- 4 files changed, 197 insertions(+), 14 deletions(-) diff --git a/features/extension-download.feature b/features/extension-download.feature index 92014bc6..1e719122 100644 --- a/features/extension-download.feature +++ b/features/extension-download.feature @@ -1,9 +1,9 @@ Feature: Download WordPress.org extensions without loading WordPress Scenario: Downloading a plugin package works before WordPress is loaded - Given a WP install + Given an empty directory - When I run `wp plugin download debug-bar --skip-wordpress` + When I run `wp plugin download debug-bar` Then STDOUT should contain: """ Downloading debug-bar @@ -16,10 +16,77 @@ Feature: Download WordPress.org extensions without loading WordPress And the {DOWNLOADED_PLUGIN} file should exist And STDERR should be empty + Scenario: Downloading a plugin package to a custom path + Given an empty directory + + When I run `wp plugin download debug-bar --path=/tmp/wp-cli-download-test-plugin` + Then STDOUT should contain: + """ + Success: Downloaded plugin package to + """ + And the /tmp/wp-cli-download-test-plugin/debug-bar file should exist + And STDERR should be empty + + Scenario: Downloading a specific version of a plugin + Given an empty directory + + When I run `wp plugin download debug-bar --version=1.4` + Then STDOUT should contain: + """ + Downloading debug-bar (1.4) + """ + And STDOUT should contain: + """ + Success: Downloaded plugin package to + """ + And STDERR should be empty + + Scenario: Downloading a non-existent version of a plugin fails with clear error + Given an empty directory + + When I try `wp plugin download debug-bar --version=9.9.9` + Then STDERR should contain: + """ + Error: Can't find the requested plugin's version 9.9.9 + """ + And the return code should be 1 + + Scenario: Downloading a plugin with --force overwrites existing file + Given an empty directory + + When I run `wp plugin download debug-bar` + And I run `wp plugin download debug-bar --force` + Then STDOUT should contain: + """ + Success: Downloaded plugin package to + """ + And STDERR should be empty + + Scenario: Downloading a plugin without --force fails if destination exists + Given an empty directory + + When I run `wp plugin download debug-bar` + And I try `wp plugin download debug-bar` + Then STDERR should contain: + """ + Error: Destination file already exists: + """ + And the return code should be 1 + + Scenario: Downloading an unknown plugin fails with a clear error + Given an empty directory + + When I try `wp plugin download this-plugin-does-not-exist-xyz-abc-123` + Then STDERR should contain: + """ + Error: The 'this-plugin-does-not-exist-xyz-abc-123' plugin could not be found. + """ + And the return code should be 1 + Scenario: Downloading a theme package works before WordPress is loaded - Given a WP install + Given an empty directory - When I run `wp theme download twentytwelve --skip-wordpress` + When I run `wp theme download twentytwelve` Then STDOUT should contain: """ Downloading twentytwelve @@ -31,3 +98,70 @@ Feature: Download WordPress.org extensions without loading WordPress And save STDOUT 'Success: Downloaded theme package to (.+)' as {DOWNLOADED_THEME} And the {DOWNLOADED_THEME} file should exist And STDERR should be empty + + Scenario: Downloading a theme package to a custom path + Given an empty directory + + When I run `wp theme download twentytwelve --path=/tmp/wp-cli-download-test-theme` + Then STDOUT should contain: + """ + Success: Downloaded theme package to + """ + And the /tmp/wp-cli-download-test-theme/twentytwelve file should exist + And STDERR should be empty + + Scenario: Downloading a specific version of a theme + Given an empty directory + + When I run `wp theme download twentytwelve --version=1.3` + Then STDOUT should contain: + """ + Downloading twentytwelve (1.3) + """ + And STDOUT should contain: + """ + Success: Downloaded theme package to + """ + And STDERR should be empty + + Scenario: Downloading a non-existent version of a theme fails with clear error + Given an empty directory + + When I try `wp theme download twentytwelve --version=9.9.9` + Then STDERR should contain: + """ + Error: Can't find the requested theme's version 9.9.9 + """ + And the return code should be 1 + + Scenario: Downloading a theme with --force overwrites existing file + Given an empty directory + + When I run `wp theme download twentytwelve` + And I run `wp theme download twentytwelve --force` + Then STDOUT should contain: + """ + Success: Downloaded theme package to + """ + And STDERR should be empty + + Scenario: Downloading a theme without --force fails if destination exists + Given an empty directory + + When I run `wp theme download twentytwelve` + And I try `wp theme download twentytwelve` + Then STDERR should contain: + """ + Error: Destination file already exists: + """ + And the return code should be 1 + + Scenario: Downloading an unknown theme fails with a clear error + Given an empty directory + + When I try `wp theme download this-theme-does-not-exist-xyz-abc-123` + Then STDERR should contain: + """ + Error: The 'this-theme-does-not-exist-xyz-abc-123' theme could not be found. + """ + And the return code should be 1 diff --git a/phpstan.neon.dist b/phpstan.neon.dist index abaa502d..41594358 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -7,6 +7,7 @@ parameters: - vendor/wp-cli/wp-cli/php scanFiles: - vendor/php-stubs/wordpress-stubs/wordpress-stubs.php + - vendor/wp-cli/wp-cli/utils/phpstan/scan-files.php treatPhpDocTypesAsCertain: false ignoreErrors: - identifier: missingType.iterableValue diff --git a/src/Plugin_Download_Command.php b/src/Plugin_Download_Command.php index 5ad4a36c..c152f1ad 100644 --- a/src/Plugin_Download_Command.php +++ b/src/Plugin_Download_Command.php @@ -41,14 +41,15 @@ class Plugin_Download_Command { * @param array{path?: string, version?: string, force?: bool, insecure?: bool} $assoc_args Associative arguments. */ public function __invoke( $args, $assoc_args ) { - $slug = (string) $args[0]; + $slug = (string) $args[0]; + if ( '' === $slug ) { + WP_CLI::error( 'Please provide a plugin slug.' ); + } + $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 ) ) { @@ -81,6 +82,22 @@ public function __invoke( $args, $assoc_args ) { } else { $download_url = str_replace( $current_zip, $slug . '.' . $requested . '.zip', $download_url ); $version = $requested; + + try { + $head_response = Utils\http_request( 'HEAD', $download_url, null, [], [ 'insecure' => (bool) $insecure ] ); + } catch ( Exception $exception ) { + WP_CLI::error( $exception->getMessage() ); + } + + if ( 200 !== (int) $head_response->status_code ) { + WP_CLI::error( + sprintf( + "Can't find the requested plugin's version %s in the WordPress.org plugin repository (HTTP code %d).", + $requested, + $head_response->status_code + ) + ); + } } } @@ -98,7 +115,7 @@ public function __invoke( $args, $assoc_args ) { WP_CLI::log( "Downloading {$slug} ({$version})..." ); try { - Utils\http_request( + $response = Utils\http_request( 'GET', $download_url, null, @@ -112,6 +129,13 @@ public function __invoke( $args, $assoc_args ) { WP_CLI::error( $exception->getMessage() ); } + if ( 200 !== (int) $response->status_code ) { + if ( file_exists( $download_file ) ) { + unlink( $download_file ); + } + WP_CLI::error( sprintf( 'Failed to download plugin package (HTTP code %d).', $response->status_code ) ); + } + WP_CLI::success( "Downloaded plugin package to {$download_file}" ); } } diff --git a/src/Theme_Download_Command.php b/src/Theme_Download_Command.php index 571fc41b..a83a207f 100644 --- a/src/Theme_Download_Command.php +++ b/src/Theme_Download_Command.php @@ -41,14 +41,15 @@ class Theme_Download_Command { * @param array{path?: string, version?: string, force?: bool, insecure?: bool} $assoc_args Associative arguments. */ public function __invoke( $args, $assoc_args ) { - $slug = (string) $args[0]; + $slug = (string) $args[0]; + if ( '' === $slug ) { + WP_CLI::error( 'Please provide a theme slug.' ); + } + $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 ) ) { @@ -81,6 +82,22 @@ public function __invoke( $args, $assoc_args ) { } else { $download_url = str_replace( $current_zip, $slug . '.' . $requested . '.zip', $download_url ); $version = $requested; + + try { + $head_response = Utils\http_request( 'HEAD', $download_url, null, [], [ 'insecure' => (bool) $insecure ] ); + } catch ( Exception $exception ) { + WP_CLI::error( $exception->getMessage() ); + } + + if ( 200 !== (int) $head_response->status_code ) { + WP_CLI::error( + sprintf( + "Can't find the requested theme's version %s in the WordPress.org theme repository (HTTP code %d).", + $requested, + $head_response->status_code + ) + ); + } } } @@ -98,7 +115,7 @@ public function __invoke( $args, $assoc_args ) { WP_CLI::log( "Downloading {$slug} ({$version})..." ); try { - Utils\http_request( + $response = Utils\http_request( 'GET', $download_url, null, @@ -112,6 +129,13 @@ public function __invoke( $args, $assoc_args ) { WP_CLI::error( $exception->getMessage() ); } + if ( 200 !== (int) $response->status_code ) { + if ( file_exists( $download_file ) ) { + unlink( $download_file ); + } + WP_CLI::error( sprintf( 'Failed to download theme package (HTTP code %d).', $response->status_code ) ); + } + WP_CLI::success( "Downloaded theme package to {$download_file}" ); } } From 767d5da5391723b59f65c2fdeec0889d868b41a2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 11:04:54 +0000 Subject: [PATCH 4/4] Fix feature test assertions to use captured file path from STDOUT Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/extension-download.feature | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/features/extension-download.feature b/features/extension-download.feature index 1e719122..cb59d82a 100644 --- a/features/extension-download.feature +++ b/features/extension-download.feature @@ -24,7 +24,8 @@ Feature: Download WordPress.org extensions without loading WordPress """ Success: Downloaded plugin package to """ - And the /tmp/wp-cli-download-test-plugin/debug-bar file should exist + 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 specific version of a plugin @@ -107,7 +108,8 @@ Feature: Download WordPress.org extensions without loading WordPress """ Success: Downloaded theme package to """ - And the /tmp/wp-cli-download-test-theme/twentytwelve file should exist + And save STDOUT 'Success: Downloaded theme package to (.+)' as {DOWNLOADED_THEME} + And the {DOWNLOADED_THEME} file should exist And STDERR should be empty Scenario: Downloading a specific version of a theme