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..f42d46ee 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,10 @@
];
WP_CLI::add_command( 'plugin', 'Plugin_Command' );
+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' );
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..f05f5eef
--- /dev/null
+++ b/features/extension-download.feature
@@ -0,0 +1,169 @@
+Feature: Download WordPress.org extensions without loading WordPress
+
+ Scenario: Downloading a plugin package works before WordPress is loaded
+ Given an empty directory
+
+ When I run `wp plugin download debug-bar`
+ 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 plugin package to a custom path
+ Given an empty directory
+
+ 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
+ """
+ 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
+ Given an empty directory
+
+ When I run `wp plugin download debug-bar --version=1.1`
+ Then STDOUT should contain:
+ """
+ Downloading debug-bar (1.1)
+ """
+ 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 an empty directory
+
+ When I run `wp theme download twentytwelve`
+ 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
+
+ Scenario: Downloading a theme package to a custom path
+ Given an empty directory
+
+ When I run `wp theme download twentytwelve --target-path=/tmp/wp-cli-download-test-theme`
+ Then 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
+
+ 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/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/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/src/Plugin_Download_Command.php b/src/Plugin_Download_Command.php
new file mode 100644
index 00000000..b3ccb8e5
--- /dev/null
+++ b/src/Plugin_Download_Command.php
@@ -0,0 +1,159 @@
+
+ * : 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.
+ */
+ public function __invoke( $args, $assoc_args ) {
+ $slug = (string) $args[0];
+ if ( '' === trim( $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, 'target-path', getcwd() ?: '.' );
+
+ 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( "The '{$slug}' plugin could not be found. " . $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;
+
+ 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
+ )
+ );
+ }
+ }
+ }
+
+ $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}" );
+ }
+
+ $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 {
+ $response = Utils\http_request(
+ 'GET',
+ $download_url,
+ null,
+ [],
+ [
+ '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( $tmp_file ) ) {
+ unlink( $tmp_file );
+ }
+ WP_CLI::error( sprintf( 'Failed to download plugin package (HTTP code %d).', $response->status_code ) );
+ }
+
+ 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
new file mode 100644
index 00000000..4d4d5870
--- /dev/null
+++ b/src/Theme_Download_Command.php
@@ -0,0 +1,159 @@
+
+ * : 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.
+ */
+ public function __invoke( $args, $assoc_args ) {
+ $slug = (string) $args[0];
+ if ( '' === trim( $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, 'target-path', getcwd() ?: '.' );
+
+ 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( "The '{$slug}' theme could not be found. " . $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;
+
+ 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
+ )
+ );
+ }
+ }
+ }
+
+ $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}" );
+ }
+
+ $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 {
+ $response = Utils\http_request(
+ 'GET',
+ $download_url,
+ null,
+ [],
+ [
+ '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( $tmp_file ) ) {
+ unlink( $tmp_file );
+ }
+ WP_CLI::error( sprintf( 'Failed to download theme package (HTTP code %d).', $response->status_code ) );
+ }
+
+ 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}" );
+ }
+}
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 @@
+