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 01/11] 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 02/11] 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 03/11] 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 04/11] 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 From 3e78d421d27c0c6d6dd466d18715f4f186d6940d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Herve=CC=81=20THOMAS?= Date: Thu, 4 Jun 2026 15:09:03 +0200 Subject: [PATCH 05/11] Fix change --path parameter to --target-path parameter to avoid clashing with global parameter https://make.wordpress.org/cli/handbook/references/config/ --- features/extension-download.feature | 4 ++-- src/Plugin_Download_Command.php | 6 +++--- src/Theme_Download_Command.php | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/features/extension-download.feature b/features/extension-download.feature index cb59d82a..49a0ffcc 100644 --- a/features/extension-download.feature +++ b/features/extension-download.feature @@ -19,7 +19,7 @@ Feature: Download WordPress.org extensions without loading WordPress 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` + When I run `wp plugin download debug-bar --target-path=/tmp/wp-cli-download-test-plugin` Then STDOUT should contain: """ Success: Downloaded plugin package to @@ -103,7 +103,7 @@ Feature: Download WordPress.org extensions without loading WordPress 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` + When I run `wp theme download twentytwelve --target-path=/tmp/wp-cli-download-test-theme` Then STDOUT should contain: """ Success: Downloaded theme package to diff --git a/src/Plugin_Download_Command.php b/src/Plugin_Download_Command.php index c152f1ad..f1785d57 100644 --- a/src/Plugin_Download_Command.php +++ b/src/Plugin_Download_Command.php @@ -11,7 +11,7 @@ * * : Slug of the plugin to download. * - * [--path=] + * [--target-path=] * : Directory to store the downloaded zip file. Defaults to the current directory. * * [--version=] @@ -38,7 +38,7 @@ 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. + * @param array{target-path?: string, version?: string, force?: bool, insecure?: bool} $assoc_args Associative arguments. */ public function __invoke( $args, $assoc_args ) { $slug = (string) $args[0]; @@ -49,7 +49,7 @@ public function __invoke( $args, $assoc_args ) { $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() ); + $download_dir = Utils\get_flag_value( $assoc_args, 'target-path', getcwd() ); if ( ! is_dir( $download_dir ) ) { if ( ! @mkdir( $download_dir, 0755, true ) ) { diff --git a/src/Theme_Download_Command.php b/src/Theme_Download_Command.php index a83a207f..2c520a6a 100644 --- a/src/Theme_Download_Command.php +++ b/src/Theme_Download_Command.php @@ -11,7 +11,7 @@ * * : Slug of the theme to download. * - * [--path=] + * [--target-path=] * : Directory to store the downloaded zip file. Defaults to the current directory. * * [--version=] @@ -38,7 +38,7 @@ 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. + * @param array{target-path?: string, version?: string, force?: bool, insecure?: bool} $assoc_args Associative arguments. */ public function __invoke( $args, $assoc_args ) { $slug = (string) $args[0]; @@ -49,7 +49,7 @@ public function __invoke( $args, $assoc_args ) { $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() ); + $download_dir = Utils\get_flag_value( $assoc_args, 'target-path', getcwd() ); if ( ! is_dir( $download_dir ) ) { if ( ! @mkdir( $download_dir, 0755, true ) ) { From caa351770245151bdbdc56979631e40700cbb9c0 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 10 Jun 2026 13:50:07 +0200 Subject: [PATCH 06/11] Undo PHPStan config change --- phpstan.neon.dist | 1 - 1 file changed, 1 deletion(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 41594358..abaa502d 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -7,7 +7,6 @@ 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 From 85c029a07d1bd7da4f8db5b1463f4fa723a781e0 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 10 Jun 2026 13:52:29 +0200 Subject: [PATCH 07/11] Add PHPStan stubs --- phpstan.neon.dist | 1 + tests/phpstan/scan-files.php | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 tests/phpstan/scan-files.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index abaa502d..3b8aeab5 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 + - tests/phpstan/scan-files.php treatPhpDocTypesAsCertain: false ignoreErrors: - identifier: missingType.iterableValue diff --git a/tests/phpstan/scan-files.php b/tests/phpstan/scan-files.php new file mode 100644 index 00000000..f6f5525f --- /dev/null +++ b/tests/phpstan/scan-files.php @@ -0,0 +1,14 @@ + Date: Wed, 10 Jun 2026 13:59:58 +0200 Subject: [PATCH 08/11] Fix synopsis --- extension-command.php | 16 ++--------- phpcs.xml.dist | 2 +- src/Plugin_Download_Command.php | 51 ++++++++++++++++----------------- src/Theme_Download_Command.php | 51 ++++++++++++++++----------------- 4 files changed, 53 insertions(+), 67 deletions(-) diff --git a/extension-command.php b/extension-command.php index 0df4c555..f42d46ee 100644 --- a/extension-command.php +++ b/extension-command.php @@ -20,22 +20,10 @@ ]; 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 download', 'Plugin_Download_Command' ); 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 download', 'Theme_Download_Command' ); 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/phpcs.xml.dist b/phpcs.xml.dist index 5b3175e3..0a51c466 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -53,7 +53,7 @@ - */src/(Plugin_(AutoUpdates_)?|Theme_(Mod_|AutoUpdates_)?)Command\.php$ + */src/(Plugin_(AutoUpdates_|Download_)?|Theme_(Mod_|AutoUpdates_|Download_)?)Command\.php$ diff --git a/src/Plugin_Download_Command.php b/src/Plugin_Download_Command.php index f1785d57..1f45924f 100644 --- a/src/Plugin_Download_Command.php +++ b/src/Plugin_Download_Command.php @@ -5,38 +5,37 @@ /** * Downloads plugin zip files from the WordPress.org repository. - * - * ## OPTIONS - * - * - * : Slug of the plugin to download. - * - * [--target-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. * + * ## OPTIONS + * + * + * : Slug of the plugin to download. + * + * [--target-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 + * * @param array{0: string} $args Positional arguments. * @param array{target-path?: string, version?: string, force?: bool, insecure?: bool} $assoc_args Associative arguments. */ diff --git a/src/Theme_Download_Command.php b/src/Theme_Download_Command.php index 2c520a6a..d7a2079b 100644 --- a/src/Theme_Download_Command.php +++ b/src/Theme_Download_Command.php @@ -5,38 +5,37 @@ /** * Downloads theme zip files from the WordPress.org repository. - * - * ## OPTIONS - * - * - * : Slug of the theme to download. - * - * [--target-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. * + * ## OPTIONS + * + * + * : Slug of the theme to download. + * + * [--target-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 + * * @param array{0: string} $args Positional arguments. * @param array{target-path?: string, version?: string, force?: bool, insecure?: bool} $assoc_args Associative arguments. */ From 1518329db3362ac468fe31c5636791b0afc2b586 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 10 Jun 2026 14:05:12 +0200 Subject: [PATCH 09/11] Fix some tests --- features/extension-download.feature | 4 ++-- src/Plugin_Download_Command.php | 2 +- src/Theme_Download_Command.php | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/features/extension-download.feature b/features/extension-download.feature index 49a0ffcc..f05f5eef 100644 --- a/features/extension-download.feature +++ b/features/extension-download.feature @@ -31,10 +31,10 @@ Feature: Download WordPress.org extensions without loading WordPress Scenario: Downloading a specific version of a plugin Given an empty directory - When I run `wp plugin download debug-bar --version=1.4` + When I run `wp plugin download debug-bar --version=1.1` Then STDOUT should contain: """ - Downloading debug-bar (1.4) + Downloading debug-bar (1.1) """ And STDOUT should contain: """ diff --git a/src/Plugin_Download_Command.php b/src/Plugin_Download_Command.php index 1f45924f..bd731283 100644 --- a/src/Plugin_Download_Command.php +++ b/src/Plugin_Download_Command.php @@ -63,7 +63,7 @@ public function __invoke( $args, $assoc_args ) { try { $plugin_data = ( new WpOrgApi( [ 'insecure' => $insecure ] ) )->get_plugin_info( $slug ); } catch ( Exception $exception ) { - WP_CLI::error( $exception->getMessage() ); + WP_CLI::error( "The '{$slug}' plugin could not be found. " . $exception->getMessage() ); } if ( ! is_array( $plugin_data ) || empty( $plugin_data['download_link'] ) || empty( $plugin_data['version'] ) ) { diff --git a/src/Theme_Download_Command.php b/src/Theme_Download_Command.php index d7a2079b..1a50dc6f 100644 --- a/src/Theme_Download_Command.php +++ b/src/Theme_Download_Command.php @@ -63,7 +63,7 @@ public function __invoke( $args, $assoc_args ) { try { $theme_data = ( new WpOrgApi( [ 'insecure' => $insecure ] ) )->get_theme_info( $slug ); } catch ( Exception $exception ) { - WP_CLI::error( $exception->getMessage() ); + WP_CLI::error( "The '{$slug}' theme could not be found. " . $exception->getMessage() ); } if ( ! is_array( $theme_data ) || empty( $theme_data['download_link'] ) || empty( $theme_data['version'] ) ) { From f85e97a192c230a892fe758d043b7753aefa53b8 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 10 Jun 2026 14:07:28 +0200 Subject: [PATCH 10/11] Trim slugs --- src/Plugin_Download_Command.php | 2 +- src/Theme_Download_Command.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Plugin_Download_Command.php b/src/Plugin_Download_Command.php index bd731283..bbfd137d 100644 --- a/src/Plugin_Download_Command.php +++ b/src/Plugin_Download_Command.php @@ -41,7 +41,7 @@ class Plugin_Download_Command { */ public function __invoke( $args, $assoc_args ) { $slug = (string) $args[0]; - if ( '' === $slug ) { + if ( '' === trim( $slug ) ) { WP_CLI::error( 'Please provide a plugin slug.' ); } diff --git a/src/Theme_Download_Command.php b/src/Theme_Download_Command.php index 1a50dc6f..d168d710 100644 --- a/src/Theme_Download_Command.php +++ b/src/Theme_Download_Command.php @@ -41,7 +41,7 @@ class Theme_Download_Command { */ public function __invoke( $args, $assoc_args ) { $slug = (string) $args[0]; - if ( '' === $slug ) { + if ( '' === trim( $slug ) ) { WP_CLI::error( 'Please provide a theme slug.' ); } From c5e471977d84085956655e7fced8fc7256514e04 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Wed, 10 Jun 2026 15:50:31 +0200 Subject: [PATCH 11/11] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/Plugin_Download_Command.php | 29 ++++++++++++++++++++++++----- src/Theme_Download_Command.php | 29 ++++++++++++++++++++++++----- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/Plugin_Download_Command.php b/src/Plugin_Download_Command.php index bbfd137d..b3ccb8e5 100644 --- a/src/Plugin_Download_Command.php +++ b/src/Plugin_Download_Command.php @@ -48,7 +48,7 @@ public function __invoke( $args, $assoc_args ) { $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, 'target-path', getcwd() ); + $download_dir = Utils\get_flag_value( $assoc_args, 'target-path', getcwd() ?: '.' ); if ( ! is_dir( $download_dir ) ) { if ( ! @mkdir( $download_dir, 0755, true ) ) { @@ -111,6 +111,13 @@ public function __invoke( $args, $assoc_args ) { WP_CLI::error( "Destination file already exists: {$download_file}" ); } + $destination_file = $download_file; + $tmp_file = $download_file; + + if ( $force && file_exists( $destination_file ) ) { + $tmp_file = $destination_file . '.tmp.' . uniqid( '', true ); + } + WP_CLI::log( "Downloading {$slug} ({$version})..." ); try { @@ -120,21 +127,33 @@ public function __invoke( $args, $assoc_args ) { null, [], [ - 'filename' => $download_file, + 'filename' => $tmp_file, 'insecure' => (bool) $insecure, ] ); } catch ( Exception $exception ) { + if ( file_exists( $tmp_file ) ) { + unlink( $tmp_file ); + } WP_CLI::error( $exception->getMessage() ); } if ( 200 !== (int) $response->status_code ) { - if ( file_exists( $download_file ) ) { - unlink( $download_file ); + if ( file_exists( $tmp_file ) ) { + unlink( $tmp_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}" ); + if ( $tmp_file !== $destination_file ) { + if ( file_exists( $destination_file ) && ! @unlink( $destination_file ) ) { + WP_CLI::error( "Failed to remove existing destination file: {$destination_file}" ); + } + if ( ! @rename( $tmp_file, $destination_file ) ) { + WP_CLI::error( "Failed to move downloaded file into place: {$destination_file}" ); + } + } + + WP_CLI::success( "Downloaded plugin package to {$destination_file}" ); } } diff --git a/src/Theme_Download_Command.php b/src/Theme_Download_Command.php index d168d710..4d4d5870 100644 --- a/src/Theme_Download_Command.php +++ b/src/Theme_Download_Command.php @@ -48,7 +48,7 @@ public function __invoke( $args, $assoc_args ) { $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, 'target-path', getcwd() ); + $download_dir = Utils\get_flag_value( $assoc_args, 'target-path', getcwd() ?: '.' ); if ( ! is_dir( $download_dir ) ) { if ( ! @mkdir( $download_dir, 0755, true ) ) { @@ -111,6 +111,13 @@ public function __invoke( $args, $assoc_args ) { WP_CLI::error( "Destination file already exists: {$download_file}" ); } + $destination_file = $download_file; + $tmp_file = $download_file; + + if ( $force && file_exists( $destination_file ) ) { + $tmp_file = $destination_file . '.tmp.' . uniqid( '', true ); + } + WP_CLI::log( "Downloading {$slug} ({$version})..." ); try { @@ -120,21 +127,33 @@ public function __invoke( $args, $assoc_args ) { null, [], [ - 'filename' => $download_file, + 'filename' => $tmp_file, 'insecure' => (bool) $insecure, ] ); } catch ( Exception $exception ) { + if ( file_exists( $tmp_file ) ) { + unlink( $tmp_file ); + } WP_CLI::error( $exception->getMessage() ); } if ( 200 !== (int) $response->status_code ) { - if ( file_exists( $download_file ) ) { - unlink( $download_file ); + if ( file_exists( $tmp_file ) ) { + unlink( $tmp_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}" ); + if ( $tmp_file !== $destination_file ) { + if ( file_exists( $destination_file ) && ! @unlink( $destination_file ) ) { + WP_CLI::error( "Failed to remove existing destination file: {$destination_file}" ); + } + if ( ! @rename( $tmp_file, $destination_file ) ) { + WP_CLI::error( "Failed to move downloaded file into place: {$destination_file}" ); + } + } + + WP_CLI::success( "Downloaded theme package to {$destination_file}" ); } }