From 55a4daa2606d299c5ced467a197870716e07ece1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 09:59:16 +0000 Subject: [PATCH 01/18] Initial plan From da838b338c647e4171509b4e681422b8607a73a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:08:02 +0000 Subject: [PATCH 02/18] Add support for installing plugins from single PHP file URLs Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/plugin-install.feature | 34 +++++++++++++ src/WP_CLI/CommandWithUpgrade.php | 81 ++++++++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/features/plugin-install.feature b/features/plugin-install.feature index f8b61ed5..3867c56b 100644 --- a/features/plugin-install.feature +++ b/features/plugin-install.feature @@ -273,3 +273,37 @@ Feature: Install WordPress plugins """ Error: No plugins installed. """ + + Scenario: Install plugin from a single PHP file URL + Given a WP install + + When I run `wp plugin install https://gist.githubusercontent.com/westonruter/dec7d190060732e29a09751ab99cc549/raw/d55866c2fc82ab16f8909ce73fc89986ab28d727/pwa-manifest-short-name.php --activate` + Then STDOUT should contain: + """ + Installing + """ + And STDOUT should contain: + """ + Downloading plugin file from + """ + And STDOUT should contain: + """ + Plugin installed successfully. + """ + And STDOUT should contain: + """ + Activating + """ + And the wp-content/plugins/pwa-manifest-short-name.php file should exist + + When I run `wp plugin list --field=name` + Then STDOUT should contain: + """ + pwa-manifest-short-name + """ + + When I run `wp plugin list --name=pwa-manifest-short-name --field=status` + Then STDOUT should be: + """ + active + """ diff --git a/src/WP_CLI/CommandWithUpgrade.php b/src/WP_CLI/CommandWithUpgrade.php index f64c8c95..5278bbdb 100755 --- a/src/WP_CLI/CommandWithUpgrade.php +++ b/src/WP_CLI/CommandWithUpgrade.php @@ -214,8 +214,25 @@ public function install( $args, $assoc_args ) { } } - // Check if a URL to a remote or local zip has been specified. - if ( $is_remote || ( pathinfo( $slug, PATHINFO_EXTENSION ) === 'zip' && is_file( $slug ) ) ) { + // Check if a URL to a remote or local PHP file has been specified (plugins only). + $url_path = $is_remote ? Utils\parse_url( $slug, PHP_URL_PATH ) : null; + if ( 'plugin' === $this->item_type && $is_remote && is_string( $url_path ) && pathinfo( $url_path, PATHINFO_EXTENSION ) === 'php' ) { + // Install from remote PHP file. + $result = $this->install_from_php_file( $slug, $assoc_args ); + + if ( is_string( $result ) ) { + // Update slug to the installed filename for activation. + $slug = $result; + $result = true; + ++$successes; + } else { + // $result is WP_Error here + WP_CLI::warning( $result->get_error_message() ); + if ( 'already_installed' !== $result->get_error_code() ) { + ++$errors; + } + } + } elseif ( $is_remote || ( pathinfo( $slug, PATHINFO_EXTENSION ) === 'zip' && is_file( $slug ) ) ) { // Install from local or remote zip file. $file_upgrader = $this->get_upgrader( $assoc_args ); @@ -318,6 +335,66 @@ public function install( $args, $assoc_args ) { Utils\report_batch_operation_results( $this->item_type, 'install', count( $args ), $successes, $errors ); } + /** + * Install a plugin from a single PHP file URL. + * + * @param string $url URL to the PHP file. + * @param array $assoc_args Associative arguments. + * @return string|WP_Error The installed filename on success, WP_Error on failure. + */ + protected function install_from_php_file( $url, $assoc_args ) { + // Ensure required WordPress files are loaded. + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + + // Download the file to a temporary location. + $temp_file = download_url( $url ); + + if ( is_wp_error( $temp_file ) ) { + return new WP_Error( 'download_failed', sprintf( 'Could not download PHP file from %s: %s', $url, $temp_file->get_error_message() ) ); + } + + // Read the plugin headers from the downloaded file. + $plugin_data = get_plugin_data( $temp_file, false, false ); + + // If no plugin name is found, use the filename. + $plugin_name = ! empty( $plugin_data['Name'] ) ? $plugin_data['Name'] : ''; + $url_path = (string) Utils\parse_url( $url, PHP_URL_PATH ); + $filename = Utils\basename( $url_path ); + + // Determine the destination filename. + $dest_filename = sanitize_file_name( $filename ); + + // Check if plugin is already installed. + $dest_path = WP_PLUGIN_DIR . '/' . $dest_filename; + if ( file_exists( $dest_path ) && ! Utils\get_flag_value( $assoc_args, 'force' ) ) { + // Clean up temp file. + unlink( $temp_file ); + return new WP_Error( 'already_installed', 'Plugin already installed.' ); + } + + // Display plugin info. + if ( ! empty( $plugin_name ) ) { + $version = ! empty( $plugin_data['Version'] ) ? $plugin_data['Version'] : ''; + WP_CLI::log( sprintf( 'Installing %s%s', $plugin_name, $version ? " ($version)" : '' ) ); + } + + WP_CLI::log( sprintf( 'Downloading plugin file from %s...', $url ) ); + + // Move the file to the plugins directory. + $result = copy( $temp_file, $dest_path ); + unlink( $temp_file ); + + if ( ! $result ) { + return new WP_Error( 'copy_failed', 'Could not copy plugin file to destination.' ); + } + + WP_CLI::log( 'Plugin installed successfully.' ); + + // Return the filename for activation purposes. + return $dest_filename; + } + /** * Prepare an API response for downloading a particular version of an item. * From 1f1924db3691f1590ab0aeadef9cfd73939cd92b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:10:53 +0000 Subject: [PATCH 03/18] Add documentation and additional tests for PHP file installation Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/plugin-install.feature | 27 +++++++++++++++++++++++++++ src/Plugin_Command.php | 9 ++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/features/plugin-install.feature b/features/plugin-install.feature index 3867c56b..c37f9840 100644 --- a/features/plugin-install.feature +++ b/features/plugin-install.feature @@ -307,3 +307,30 @@ Feature: Install WordPress plugins """ active """ + + Scenario: Install plugin from a single PHP file URL with --force flag + Given a WP install + + When I run `wp plugin install https://gist.githubusercontent.com/westonruter/dec7d190060732e29a09751ab99cc549/raw/d55866c2fc82ab16f8909ce73fc89986ab28d727/pwa-manifest-short-name.php` + Then STDOUT should contain: + """ + Plugin installed successfully. + """ + And the wp-content/plugins/pwa-manifest-short-name.php file should exist + + When I try `wp plugin install https://gist.githubusercontent.com/westonruter/dec7d190060732e29a09751ab99cc549/raw/d55866c2fc82ab16f8909ce73fc89986ab28d727/pwa-manifest-short-name.php` + Then STDERR should contain: + """ + Warning: Plugin already installed. + """ + And STDOUT should contain: + """ + Success: Plugin already installed. + """ + + When I run `wp plugin install https://gist.githubusercontent.com/westonruter/dec7d190060732e29a09751ab99cc549/raw/d55866c2fc82ab16f8909ce73fc89986ab28d727/pwa-manifest-short-name.php --force` + Then STDOUT should contain: + """ + Plugin installed successfully. + """ + And the wp-content/plugins/pwa-manifest-short-name.php file should exist diff --git a/src/Plugin_Command.php b/src/Plugin_Command.php index ef5b061f..a00383a3 100644 --- a/src/Plugin_Command.php +++ b/src/Plugin_Command.php @@ -994,7 +994,7 @@ protected function filter_item_list( $items, $args ) { * ## OPTIONS * * ... - * : One or more plugins to install. Accepts a plugin slug, the path to a local zip file, or a URL to a remote zip file. + * : One or more plugins to install. Accepts a plugin slug, the path to a local zip file, or a URL to a remote zip file or PHP file. * * [--version=] * : If set, get that particular version from wordpress.org, instead of the @@ -1073,6 +1073,13 @@ protected function filter_item_list( $items, $args ) { * Removing the old version of the plugin... * Plugin updated successfully * Success: Installed 1 of 1 plugins. + * + * # Install from a remote PHP file + * $ wp plugin install https://example.com/my-plugin.php + * Installing My Plugin (1.0.0) + * Downloading plugin file from https://example.com/my-plugin.php... + * Plugin installed successfully. + * Success: Installed 1 of 1 plugins. */ public function install( $args, $assoc_args ) { From 075cb793b1017ac782dbc875f44676e798105edd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:12:46 +0000 Subject: [PATCH 04/18] Address code review feedback: add validation and improve code structure Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/WP_CLI/CommandWithUpgrade.php | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/WP_CLI/CommandWithUpgrade.php b/src/WP_CLI/CommandWithUpgrade.php index 5278bbdb..ccc33cbb 100755 --- a/src/WP_CLI/CommandWithUpgrade.php +++ b/src/WP_CLI/CommandWithUpgrade.php @@ -215,8 +215,7 @@ public function install( $args, $assoc_args ) { } // Check if a URL to a remote or local PHP file has been specified (plugins only). - $url_path = $is_remote ? Utils\parse_url( $slug, PHP_URL_PATH ) : null; - if ( 'plugin' === $this->item_type && $is_remote && is_string( $url_path ) && pathinfo( $url_path, PATHINFO_EXTENSION ) === 'php' ) { + if ( $this->is_php_file_url( $slug, $is_remote ) ) { // Install from remote PHP file. $result = $this->install_from_php_file( $slug, $assoc_args ); @@ -351,7 +350,7 @@ protected function install_from_php_file( $url, $assoc_args ) { $temp_file = download_url( $url ); if ( is_wp_error( $temp_file ) ) { - return new WP_Error( 'download_failed', sprintf( 'Could not download PHP file from %s: %s', $url, $temp_file->get_error_message() ) ); + return new WP_Error( 'download_failed', sprintf( 'Could not download PHP file from %s: %s', esc_url( $url ), $temp_file->get_error_message() ) ); } // Read the plugin headers from the downloaded file. @@ -365,6 +364,12 @@ protected function install_from_php_file( $url, $assoc_args ) { // Determine the destination filename. $dest_filename = sanitize_file_name( $filename ); + // Ensure the sanitized filename still has a .php extension. + if ( pathinfo( $dest_filename, PATHINFO_EXTENSION ) !== 'php' ) { + unlink( $temp_file ); + return new WP_Error( 'invalid_filename', 'The sanitized filename does not have a .php extension.' ); + } + // Check if plugin is already installed. $dest_path = WP_PLUGIN_DIR . '/' . $dest_filename; if ( file_exists( $dest_path ) && ! Utils\get_flag_value( $assoc_args, 'force' ) ) { @@ -379,7 +384,7 @@ protected function install_from_php_file( $url, $assoc_args ) { WP_CLI::log( sprintf( 'Installing %s%s', $plugin_name, $version ? " ($version)" : '' ) ); } - WP_CLI::log( sprintf( 'Downloading plugin file from %s...', $url ) ); + WP_CLI::log( sprintf( 'Downloading plugin file from %s...', esc_url( $url ) ) ); // Move the file to the plugins directory. $result = copy( $temp_file, $dest_path ); @@ -395,6 +400,22 @@ protected function install_from_php_file( $url, $assoc_args ) { return $dest_filename; } + /** + * Check if a URL points to a PHP file for plugin installation. + * + * @param string $slug The slug/URL to check. + * @param bool $is_remote Whether the slug is a remote URL. + * @return bool True if it's a PHP file URL for plugin installation. + */ + protected function is_php_file_url( $slug, $is_remote ) { + if ( 'plugin' !== $this->item_type || ! $is_remote ) { + return false; + } + + $url_path = Utils\parse_url( $slug, PHP_URL_PATH ); + return is_string( $url_path ) && pathinfo( $url_path, PATHINFO_EXTENSION ) === 'php'; + } + /** * Prepare an API response for downloading a particular version of an item. * From 0b4d7aad632facc6e9bd7837af9d0db0901c34e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:14:38 +0000 Subject: [PATCH 05/18] Add security validations for path traversal and improve UX with download message timing Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/WP_CLI/CommandWithUpgrade.php | 45 +++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/src/WP_CLI/CommandWithUpgrade.php b/src/WP_CLI/CommandWithUpgrade.php index ccc33cbb..27bad73c 100755 --- a/src/WP_CLI/CommandWithUpgrade.php +++ b/src/WP_CLI/CommandWithUpgrade.php @@ -346,6 +346,37 @@ protected function install_from_php_file( $url, $assoc_args ) { require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/plugin.php'; + // Extract and validate filename before downloading. + $url_path = (string) Utils\parse_url( $url, PHP_URL_PATH ); + $filename = Utils\basename( $url_path ); + + // Validate the filename doesn't contain directory separators or relative path components. + if ( strpos( $filename, '/' ) !== false || strpos( $filename, '\\' ) !== false || strpos( $filename, '..' ) !== false ) { + return new WP_Error( 'invalid_filename', 'The filename contains invalid path components.' ); + } + + // Determine the destination filename and validate extension. + $dest_filename = sanitize_file_name( $filename ); + + // Ensure the sanitized filename still has a .php extension. + if ( pathinfo( $dest_filename, PATHINFO_EXTENSION ) !== 'php' ) { + return new WP_Error( 'invalid_filename', 'The sanitized filename does not have a .php extension.' ); + } + + // Validate the destination stays within the plugin directory. + $dest_path = trailingslashit( WP_PLUGIN_DIR ) . $dest_filename; + $real_dest = realpath( WP_PLUGIN_DIR ); + if ( false !== $real_dest ) { + // Ensure destination is within plugin directory (prevent directory traversal). + $dest_dir = dirname( $dest_path ); + if ( 0 !== strpos( $dest_dir, $real_dest ) ) { + return new WP_Error( 'invalid_path', 'The destination path is outside the plugin directory.' ); + } + } + + // Display info message before downloading. + WP_CLI::log( sprintf( 'Downloading plugin file from %s...', esc_url( $url ) ) ); + // Download the file to a temporary location. $temp_file = download_url( $url ); @@ -358,20 +389,8 @@ protected function install_from_php_file( $url, $assoc_args ) { // If no plugin name is found, use the filename. $plugin_name = ! empty( $plugin_data['Name'] ) ? $plugin_data['Name'] : ''; - $url_path = (string) Utils\parse_url( $url, PHP_URL_PATH ); - $filename = Utils\basename( $url_path ); - - // Determine the destination filename. - $dest_filename = sanitize_file_name( $filename ); - - // Ensure the sanitized filename still has a .php extension. - if ( pathinfo( $dest_filename, PATHINFO_EXTENSION ) !== 'php' ) { - unlink( $temp_file ); - return new WP_Error( 'invalid_filename', 'The sanitized filename does not have a .php extension.' ); - } // Check if plugin is already installed. - $dest_path = WP_PLUGIN_DIR . '/' . $dest_filename; if ( file_exists( $dest_path ) && ! Utils\get_flag_value( $assoc_args, 'force' ) ) { // Clean up temp file. unlink( $temp_file ); @@ -384,8 +403,6 @@ protected function install_from_php_file( $url, $assoc_args ) { WP_CLI::log( sprintf( 'Installing %s%s', $plugin_name, $version ? " ($version)" : '' ) ); } - WP_CLI::log( sprintf( 'Downloading plugin file from %s...', esc_url( $url ) ) ); - // Move the file to the plugins directory. $result = copy( $temp_file, $dest_path ); unlink( $temp_file ); From 78e54f7710650a5d141c24cec15431bcac9bd357 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:16:29 +0000 Subject: [PATCH 06/18] Fix security validations: properly check path traversal and validate plugin headers Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/WP_CLI/CommandWithUpgrade.php | 38 ++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/WP_CLI/CommandWithUpgrade.php b/src/WP_CLI/CommandWithUpgrade.php index 27bad73c..e68183dd 100755 --- a/src/WP_CLI/CommandWithUpgrade.php +++ b/src/WP_CLI/CommandWithUpgrade.php @@ -363,15 +363,24 @@ protected function install_from_php_file( $url, $assoc_args ) { return new WP_Error( 'invalid_filename', 'The sanitized filename does not have a .php extension.' ); } + // Ensure plugin directory exists. + if ( ! is_dir( WP_PLUGIN_DIR ) ) { + wp_mkdir_p( WP_PLUGIN_DIR ); + } + // Validate the destination stays within the plugin directory. $dest_path = trailingslashit( WP_PLUGIN_DIR ) . $dest_filename; $real_dest = realpath( WP_PLUGIN_DIR ); - if ( false !== $real_dest ) { - // Ensure destination is within plugin directory (prevent directory traversal). - $dest_dir = dirname( $dest_path ); - if ( 0 !== strpos( $dest_dir, $real_dest ) ) { - return new WP_Error( 'invalid_path', 'The destination path is outside the plugin directory.' ); - } + $real_path = realpath( dirname( $dest_path ) ); + + // Ensure plugin directory and destination parent directory are valid. + if ( false === $real_dest || false === $real_path ) { + return new WP_Error( 'invalid_path', 'Cannot validate plugin directory path.' ); + } + + // Ensure destination is within plugin directory (prevent directory traversal). + if ( 0 !== strpos( $real_path, $real_dest ) ) { + return new WP_Error( 'invalid_path', 'The destination path is outside the plugin directory.' ); } // Display info message before downloading. @@ -384,11 +393,16 @@ protected function install_from_php_file( $url, $assoc_args ) { return new WP_Error( 'download_failed', sprintf( 'Could not download PHP file from %s: %s', esc_url( $url ), $temp_file->get_error_message() ) ); } - // Read the plugin headers from the downloaded file. + // Verify the downloaded file is a valid PHP file with plugin headers. $plugin_data = get_plugin_data( $temp_file, false, false ); - // If no plugin name is found, use the filename. - $plugin_name = ! empty( $plugin_data['Name'] ) ? $plugin_data['Name'] : ''; + // Verify this is actually a plugin file with at least a plugin name. + if ( empty( $plugin_data['Name'] ) ) { + unlink( $temp_file ); + return new WP_Error( 'invalid_plugin', 'The downloaded file does not appear to be a valid WordPress plugin.' ); + } + + $plugin_name = $plugin_data['Name']; // Check if plugin is already installed. if ( file_exists( $dest_path ) && ! Utils\get_flag_value( $assoc_args, 'force' ) ) { @@ -398,10 +412,8 @@ protected function install_from_php_file( $url, $assoc_args ) { } // Display plugin info. - if ( ! empty( $plugin_name ) ) { - $version = ! empty( $plugin_data['Version'] ) ? $plugin_data['Version'] : ''; - WP_CLI::log( sprintf( 'Installing %s%s', $plugin_name, $version ? " ($version)" : '' ) ); - } + $version = ! empty( $plugin_data['Version'] ) ? $plugin_data['Version'] : ''; + WP_CLI::log( sprintf( 'Installing %s%s', $plugin_name, $version ? " ($version)" : '' ) ); // Move the file to the plugins directory. $result = copy( $temp_file, $dest_path ); From 82f78194bbe4663e9332afea44ca63a6d2928c69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:18:17 +0000 Subject: [PATCH 07/18] Optimize: check file existence before download and simplify path validation Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/WP_CLI/CommandWithUpgrade.php | 32 +++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/WP_CLI/CommandWithUpgrade.php b/src/WP_CLI/CommandWithUpgrade.php index e68183dd..1d4cf0a8 100755 --- a/src/WP_CLI/CommandWithUpgrade.php +++ b/src/WP_CLI/CommandWithUpgrade.php @@ -363,23 +363,30 @@ protected function install_from_php_file( $url, $assoc_args ) { return new WP_Error( 'invalid_filename', 'The sanitized filename does not have a .php extension.' ); } + // Construct destination path. + $dest_path = trailingslashit( WP_PLUGIN_DIR ) . $dest_filename; + + // Check if plugin is already installed before downloading. + if ( file_exists( $dest_path ) && ! Utils\get_flag_value( $assoc_args, 'force' ) ) { + return new WP_Error( 'already_installed', 'Plugin already installed.' ); + } + // Ensure plugin directory exists. if ( ! is_dir( WP_PLUGIN_DIR ) ) { wp_mkdir_p( WP_PLUGIN_DIR ); } - // Validate the destination stays within the plugin directory. - $dest_path = trailingslashit( WP_PLUGIN_DIR ) . $dest_filename; - $real_dest = realpath( WP_PLUGIN_DIR ); - $real_path = realpath( dirname( $dest_path ) ); - - // Ensure plugin directory and destination parent directory are valid. - if ( false === $real_dest || false === $real_path ) { + // Validate the destination stays within the plugin directory (prevent directory traversal). + // Since single-file plugins are installed directly in WP_PLUGIN_DIR, we just need to ensure + // the destination resolves to a file within WP_PLUGIN_DIR. + $real_plugin_dir = realpath( WP_PLUGIN_DIR ); + if ( false === $real_plugin_dir ) { return new WP_Error( 'invalid_path', 'Cannot validate plugin directory path.' ); } - // Ensure destination is within plugin directory (prevent directory traversal). - if ( 0 !== strpos( $real_path, $real_dest ) ) { + // Verify the constructed path is within the plugin directory. + $expected_path = trailingslashit( $real_plugin_dir ) . $dest_filename; + if ( realpath( dirname( $dest_path ) ) !== $real_plugin_dir ) { return new WP_Error( 'invalid_path', 'The destination path is outside the plugin directory.' ); } @@ -404,13 +411,6 @@ protected function install_from_php_file( $url, $assoc_args ) { $plugin_name = $plugin_data['Name']; - // Check if plugin is already installed. - if ( file_exists( $dest_path ) && ! Utils\get_flag_value( $assoc_args, 'force' ) ) { - // Clean up temp file. - unlink( $temp_file ); - return new WP_Error( 'already_installed', 'Plugin already installed.' ); - } - // Display plugin info. $version = ! empty( $plugin_data['Version'] ) ? $plugin_data['Version'] : ''; WP_CLI::log( sprintf( 'Installing %s%s', $plugin_name, $version ? " ($version)" : '' ) ); From 551fe3eeac6554305f9e7c9d2b2b292bab6efc36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:38:30 +0000 Subject: [PATCH 08/18] Add support for GitHub Gist page URLs (gist.github.com) Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/plugin-install.feature | 32 ++++++++++++ src/WP_CLI/CommandWithUpgrade.php | 84 +++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/features/plugin-install.feature b/features/plugin-install.feature index c37f9840..426f7f1b 100644 --- a/features/plugin-install.feature +++ b/features/plugin-install.feature @@ -334,3 +334,35 @@ Feature: Install WordPress plugins Plugin installed successfully. """ And the wp-content/plugins/pwa-manifest-short-name.php file should exist + + Scenario: Install plugin from a GitHub Gist page URL + Given a WP install + + When I run `wp plugin install https://gist.github.com/westonruter/dec7d190060732e29a09751ab99cc549 --activate` + Then STDOUT should contain: + """ + Gist resolved to raw file URL. + """ + And STDOUT should contain: + """ + Installing + """ + And STDOUT should contain: + """ + Downloading plugin file from + """ + And STDOUT should contain: + """ + Plugin installed successfully. + """ + And STDOUT should contain: + """ + Activating + """ + And the wp-content/plugins/pwa-manifest-short-name.php file should exist + + When I run `wp plugin list --name=pwa-manifest-short-name --field=status` + Then STDOUT should be: + """ + active + """ diff --git a/src/WP_CLI/CommandWithUpgrade.php b/src/WP_CLI/CommandWithUpgrade.php index 1d4cf0a8..d5002a2d 100755 --- a/src/WP_CLI/CommandWithUpgrade.php +++ b/src/WP_CLI/CommandWithUpgrade.php @@ -212,6 +212,19 @@ public function install( $args, $assoc_args ) { WP_CLI::log( 'Latest release resolved to ' . $version['name'] ); } + + // Check if it's a GitHub Gist page URL and convert to raw URL + $gist_id = $this->get_gist_id_from_url( $slug ); + if ( $gist_id && 'plugin' === $this->item_type ) { + $raw_url = $this->get_raw_url_from_gist( $gist_id ); + + if ( is_wp_error( $raw_url ) ) { + WP_CLI::error( $raw_url->get_error_message() ); + } + + WP_CLI::log( 'Gist resolved to raw file URL.' ); + $slug = $raw_url; + } } // Check if a URL to a remote or local PHP file has been specified (plugins only). @@ -1140,4 +1153,75 @@ protected function get_github_repo_from_releases_url( $url ) { private function build_rate_limiting_error_message( $decoded_body ) { return $decoded_body->message . PHP_EOL . $decoded_body->documentation_url . PHP_EOL . 'In order to pass the token to WP-CLI, you need to use the GITHUB_TOKEN environment variable.'; } + + /** + * Check if a URL is a GitHub Gist page URL (not the raw URL). + * + * @param string $url The URL to check. + * @return string|null The gist ID if it's a gist URL, null otherwise. + */ + protected function get_gist_id_from_url( $url ) { + // Match gist.github.com URLs but not gist.githubusercontent.com (raw URLs) + if ( preg_match( '#^https?://gist\.github\.com/[^/]+/([a-f0-9]+)/?$#i', $url, $matches ) ) { + return $matches[1]; + } + return null; + } + + /** + * Convert a GitHub Gist page URL to a raw PHP file URL. + * + * @param string $gist_id The gist ID. + * @return string|WP_Error The raw URL of the first PHP file in the gist, or WP_Error on failure. + */ + protected function get_raw_url_from_gist( $gist_id ) { + $api_url = 'https://api.github.com/gists/' . $gist_id; + $token = getenv( 'GITHUB_TOKEN' ); + + $request_arguments = $token ? [ 'headers' => [ 'Authorization' => 'Bearer ' . $token ] ] : []; + + $response = \wp_remote_get( $api_url, $request_arguments ); + + if ( \is_wp_error( $response ) ) { + return $response; + } + + $response_code = wp_remote_retrieve_response_code( $response ); + $body = \wp_remote_retrieve_body( $response ); + $decoded_body = json_decode( $body ); + + if ( 403 === $response_code ) { + return new \WP_Error( + 403, + $this->build_rate_limiting_error_message( $decoded_body ) + ); + } + + if ( 404 === $response_code ) { + return new \WP_Error( 404, 'Gist not found.' ); + } + + if ( null === $decoded_body || ! is_object( $decoded_body ) || ! isset( $decoded_body->files ) ) { + return new \WP_Error( 500, 'Invalid response from GitHub Gist API.' ); + } + + // Find PHP files in the gist + $php_files = []; + $files = (array) $decoded_body->files; + foreach ( $files as $filename => $file_data ) { + if ( is_object( $file_data ) && isset( $file_data->raw_url ) && pathinfo( $filename, PATHINFO_EXTENSION ) === 'php' ) { + $php_files[] = [ + 'name' => $filename, + 'raw_url' => $file_data->raw_url, + ]; + } + } + + if ( empty( $php_files ) ) { + return new \WP_Error( 'no_php_files', 'No PHP files found in the gist.' ); + } + + // Return the first PHP file found + return $php_files[0]['raw_url']; + } } From 3a4d190b146fdc35f4b5a6923e66a247deffe8e9 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 19 Jan 2026 17:18:09 +0100 Subject: [PATCH 09/18] Update src/WP_CLI/CommandWithUpgrade.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/WP_CLI/CommandWithUpgrade.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/WP_CLI/CommandWithUpgrade.php b/src/WP_CLI/CommandWithUpgrade.php index d5002a2d..bdcdd3b1 100755 --- a/src/WP_CLI/CommandWithUpgrade.php +++ b/src/WP_CLI/CommandWithUpgrade.php @@ -398,7 +398,6 @@ protected function install_from_php_file( $url, $assoc_args ) { } // Verify the constructed path is within the plugin directory. - $expected_path = trailingslashit( $real_plugin_dir ) . $dest_filename; if ( realpath( dirname( $dest_path ) ) !== $real_plugin_dir ) { return new WP_Error( 'invalid_path', 'The destination path is outside the plugin directory.' ); } From 584d5054cc515ee91fcef7798303d5c0a9ede0f4 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 19 Jan 2026 17:18:45 +0100 Subject: [PATCH 10/18] Update src/WP_CLI/CommandWithUpgrade.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/WP_CLI/CommandWithUpgrade.php | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/WP_CLI/CommandWithUpgrade.php b/src/WP_CLI/CommandWithUpgrade.php index bdcdd3b1..cea7756e 100755 --- a/src/WP_CLI/CommandWithUpgrade.php +++ b/src/WP_CLI/CommandWithUpgrade.php @@ -1191,17 +1191,26 @@ protected function get_raw_url_from_gist( $gist_id ) { if ( 403 === $response_code ) { return new \WP_Error( - 403, - $this->build_rate_limiting_error_message( $decoded_body ) + 'api_rate_limit', + $this->build_rate_limiting_error_message( $decoded_body ), + [ 'status' => 403 ] ); } if ( 404 === $response_code ) { - return new \WP_Error( 404, 'Gist not found.' ); + return new \WP_Error( + 'gist_not_found', + 'Gist not found.', + [ 'status' => 404 ] + ); } if ( null === $decoded_body || ! is_object( $decoded_body ) || ! isset( $decoded_body->files ) ) { - return new \WP_Error( 500, 'Invalid response from GitHub Gist API.' ); + return new \WP_Error( + 'invalid_gist_api_response', + 'Invalid response from GitHub Gist API.', + [ 'status' => 500 ] + ); } // Find PHP files in the gist From 9f699f16bcb57fc064735a0edbd0598e27964c8f Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 19 Jan 2026 17:18:59 +0100 Subject: [PATCH 11/18] Update src/WP_CLI/CommandWithUpgrade.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/WP_CLI/CommandWithUpgrade.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/WP_CLI/CommandWithUpgrade.php b/src/WP_CLI/CommandWithUpgrade.php index cea7756e..f1fea1b6 100755 --- a/src/WP_CLI/CommandWithUpgrade.php +++ b/src/WP_CLI/CommandWithUpgrade.php @@ -364,6 +364,8 @@ protected function install_from_php_file( $url, $assoc_args ) { $filename = Utils\basename( $url_path ); // Validate the filename doesn't contain directory separators or relative path components. + // Note: Utils\basename() already strips directory components (including ".."), so this check + // is primarily a defense-in-depth safeguard in case its behavior changes or is bypassed. if ( strpos( $filename, '/' ) !== false || strpos( $filename, '\\' ) !== false || strpos( $filename, '..' ) !== false ) { return new WP_Error( 'invalid_filename', 'The filename contains invalid path components.' ); } From 232e96b0ddd01ebd9963b7d6516d8c7edcae750b Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 22 Jan 2026 10:15:04 +0100 Subject: [PATCH 12/18] Update src/WP_CLI/CommandWithUpgrade.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/WP_CLI/CommandWithUpgrade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WP_CLI/CommandWithUpgrade.php b/src/WP_CLI/CommandWithUpgrade.php index f1fea1b6..83a21f26 100755 --- a/src/WP_CLI/CommandWithUpgrade.php +++ b/src/WP_CLI/CommandWithUpgrade.php @@ -1179,7 +1179,7 @@ protected function get_raw_url_from_gist( $gist_id ) { $api_url = 'https://api.github.com/gists/' . $gist_id; $token = getenv( 'GITHUB_TOKEN' ); - $request_arguments = $token ? [ 'headers' => [ 'Authorization' => 'Bearer ' . $token ] ] : []; + $request_arguments = $token ? [ 'headers' => 'Authorization: Bearer ' . $token ] : []; $response = \wp_remote_get( $api_url, $request_arguments ); From 881032e5165632c11ecd4d8cc0d2cb180a69ff8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 Jan 2026 09:18:07 +0000 Subject: [PATCH 13/18] Improve error handling and validation in gist URL processing - Add specific error handling for HTTP codes 401, 500, 503 - Add fallback for malformed rate limit responses - Verify plugin directory creation after wp_mkdir_p() - Fix regex pattern to properly match uppercase hex in gist IDs - Add catch-all for unexpected HTTP status codes Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/WP_CLI/CommandWithUpgrade.php | 56 ++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/src/WP_CLI/CommandWithUpgrade.php b/src/WP_CLI/CommandWithUpgrade.php index 83a21f26..3f7c6e34 100755 --- a/src/WP_CLI/CommandWithUpgrade.php +++ b/src/WP_CLI/CommandWithUpgrade.php @@ -389,6 +389,11 @@ protected function install_from_php_file( $url, $assoc_args ) { // Ensure plugin directory exists. if ( ! is_dir( WP_PLUGIN_DIR ) ) { wp_mkdir_p( WP_PLUGIN_DIR ); + + // Verify that the plugin directory was successfully created. + if ( ! is_dir( WP_PLUGIN_DIR ) ) { + return new WP_Error( 'invalid_path', 'Unable to create plugin directory.' ); + } } // Validate the destination stays within the plugin directory (prevent directory traversal). @@ -1163,7 +1168,8 @@ private function build_rate_limiting_error_message( $decoded_body ) { */ protected function get_gist_id_from_url( $url ) { // Match gist.github.com URLs but not gist.githubusercontent.com (raw URLs) - if ( preg_match( '#^https?://gist\.github\.com/[^/]+/([a-f0-9]+)/?$#i', $url, $matches ) ) { + // Gist IDs are hexadecimal strings that can contain both lowercase and uppercase + if ( preg_match( '#^https?://gist\.github\.com/[^/]+/([a-fA-F0-9]+)/?$#', $url, $matches ) ) { return $matches[1]; } return null; @@ -1191,10 +1197,27 @@ protected function get_raw_url_from_gist( $gist_id ) { $body = \wp_remote_retrieve_body( $response ); $decoded_body = json_decode( $body ); + // Handle common HTTP error codes with specific error messages + if ( 401 === $response_code ) { + return new \WP_Error( + 'github_unauthorized', + 'Unauthorized: Invalid or missing GitHub token.', + [ 'status' => 401 ] + ); + } + if ( 403 === $response_code ) { + // Check if decoded_body is valid before using it + if ( null !== $decoded_body && is_object( $decoded_body ) ) { + return new \WP_Error( + 'github_rate_limited', + $this->build_rate_limiting_error_message( $decoded_body ), + [ 'status' => 403 ] + ); + } return new \WP_Error( - 'api_rate_limit', - $this->build_rate_limiting_error_message( $decoded_body ), + 'github_forbidden', + 'Access forbidden. This may be due to rate limiting or insufficient permissions.', [ 'status' => 403 ] ); } @@ -1207,11 +1230,36 @@ protected function get_raw_url_from_gist( $gist_id ) { ); } + if ( 500 === $response_code ) { + return new \WP_Error( + 'github_server_error', + 'GitHub server error. Please try again later.', + [ 'status' => 500 ] + ); + } + + if ( 503 === $response_code ) { + return new \WP_Error( + 'github_unavailable', + 'GitHub service is temporarily unavailable. Please try again later.', + [ 'status' => 503 ] + ); + } + + // Check for other non-2xx status codes + if ( $response_code < 200 || $response_code >= 300 ) { + return new \WP_Error( + 'github_api_error', + sprintf( 'GitHub API returned unexpected status code: %d', $response_code ), + [ 'status' => $response_code ] + ); + } + if ( null === $decoded_body || ! is_object( $decoded_body ) || ! isset( $decoded_body->files ) ) { return new \WP_Error( 'invalid_gist_api_response', 'Invalid response from GitHub Gist API.', - [ 'status' => 500 ] + [ 'status' => $response_code ] ); } From 264d2aa0967f9bf4b9b6ebcc20b73866820a414d Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 22 Jan 2026 10:28:57 +0100 Subject: [PATCH 14/18] Update src/WP_CLI/CommandWithUpgrade.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/WP_CLI/CommandWithUpgrade.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/WP_CLI/CommandWithUpgrade.php b/src/WP_CLI/CommandWithUpgrade.php index 3f7c6e34..b7dab8bc 100755 --- a/src/WP_CLI/CommandWithUpgrade.php +++ b/src/WP_CLI/CommandWithUpgrade.php @@ -394,6 +394,11 @@ protected function install_from_php_file( $url, $assoc_args ) { if ( ! is_dir( WP_PLUGIN_DIR ) ) { return new WP_Error( 'invalid_path', 'Unable to create plugin directory.' ); } + + // Verify that the plugin directory was successfully created. + if ( ! is_dir( WP_PLUGIN_DIR ) ) { + return new WP_Error( 'invalid_path', 'Unable to create plugin directory.' ); + } } // Validate the destination stays within the plugin directory (prevent directory traversal). From cb58c2802f692445bb41b8e76665c0b90d4c352d Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 22 Jan 2026 12:35:12 +0100 Subject: [PATCH 15/18] remove double check --- src/WP_CLI/CommandWithUpgrade.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/WP_CLI/CommandWithUpgrade.php b/src/WP_CLI/CommandWithUpgrade.php index e16b846c..b55342b1 100755 --- a/src/WP_CLI/CommandWithUpgrade.php +++ b/src/WP_CLI/CommandWithUpgrade.php @@ -405,11 +405,6 @@ protected function install_from_php_file( $url, $assoc_args ) { if ( ! is_dir( WP_PLUGIN_DIR ) ) { return new WP_Error( 'invalid_path', 'Unable to create plugin directory.' ); } - - // Verify that the plugin directory was successfully created. - if ( ! is_dir( WP_PLUGIN_DIR ) ) { - return new WP_Error( 'invalid_path', 'Unable to create plugin directory.' ); - } } // Validate the destination stays within the plugin directory (prevent directory traversal). From ff342c975cfdcc8193f719bce11aea2405d86d62 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 22 Jan 2026 12:39:26 +0100 Subject: [PATCH 16/18] Apply suggestion from @swissspidy --- src/Plugin_Command.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Plugin_Command.php b/src/Plugin_Command.php index b0227e70..98ceb27f 100644 --- a/src/Plugin_Command.php +++ b/src/Plugin_Command.php @@ -1091,6 +1091,7 @@ protected function filter_item_list( $items, $args ) { * $ wp plugin install https://example.com/my-plugin.php * Installing My Plugin (1.0.0) * Downloading plugin file from https://example.com/my-plugin.php... + * * # Install a plugin with all its dependencies * $ wp plugin install my-plugin --with-dependencies * Installing Required Plugin 1 (1.2.3) From aa00d3872f3174761bd89118d9446dbadeb96d03 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 Jan 2026 11:42:15 +0000 Subject: [PATCH 17/18] Support anonymous gists by making username optional in regex Anonymous gists don't have a username in the URL path (e.g., gist.github.com/1234567890abcdef). Updated regex to make the username segment optional using non-capturing group (?:[^/]+/)? so both user-owned and anonymous gists are supported. Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/WP_CLI/CommandWithUpgrade.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/WP_CLI/CommandWithUpgrade.php b/src/WP_CLI/CommandWithUpgrade.php index b55342b1..cc7fa63f 100755 --- a/src/WP_CLI/CommandWithUpgrade.php +++ b/src/WP_CLI/CommandWithUpgrade.php @@ -1179,8 +1179,9 @@ private function build_rate_limiting_error_message( $decoded_body ) { */ protected function get_gist_id_from_url( $url ) { // Match gist.github.com URLs but not gist.githubusercontent.com (raw URLs) + // Supports both user-owned gists (gist.github.com/username/id) and anonymous gists (gist.github.com/id) // Gist IDs are hexadecimal strings that can contain both lowercase and uppercase - if ( preg_match( '#^https?://gist\.github\.com/[^/]+/([a-fA-F0-9]+)/?$#', $url, $matches ) ) { + if ( preg_match( '#^https?://gist\.github\.com/(?:[^/]+/)?([a-fA-F0-9]+)/?$#', $url, $matches ) ) { return $matches[1]; } return null; From aadf613f378eec3fff1d442edd8eaa6f72a60cd3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 22 Jan 2026 11:45:32 +0000 Subject: [PATCH 18/18] Make PHP extension checks case-insensitive Convert extension to lowercase before comparison in all PHP extension checks to handle files with uppercase extensions (e.g., .PHP, .Php). This makes the validation more robust and consistent across: - Sanitized filename validation - URL path detection - Gist file filtering Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/WP_CLI/CommandWithUpgrade.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/WP_CLI/CommandWithUpgrade.php b/src/WP_CLI/CommandWithUpgrade.php index cc7fa63f..2323aabf 100755 --- a/src/WP_CLI/CommandWithUpgrade.php +++ b/src/WP_CLI/CommandWithUpgrade.php @@ -384,8 +384,8 @@ protected function install_from_php_file( $url, $assoc_args ) { // Determine the destination filename and validate extension. $dest_filename = sanitize_file_name( $filename ); - // Ensure the sanitized filename still has a .php extension. - if ( pathinfo( $dest_filename, PATHINFO_EXTENSION ) !== 'php' ) { + // Ensure the sanitized filename still has a .php extension (case-insensitive). + if ( strtolower( pathinfo( $dest_filename, PATHINFO_EXTENSION ) ) !== 'php' ) { return new WP_Error( 'invalid_filename', 'The sanitized filename does not have a .php extension.' ); } @@ -472,7 +472,7 @@ protected function is_php_file_url( $slug, $is_remote ) { } $url_path = Utils\parse_url( $slug, PHP_URL_PATH ); - return is_string( $url_path ) && pathinfo( $url_path, PATHINFO_EXTENSION ) === 'php'; + return is_string( $url_path ) && strtolower( pathinfo( $url_path, PATHINFO_EXTENSION ) ) === 'php'; } /** @@ -1279,7 +1279,7 @@ protected function get_raw_url_from_gist( $gist_id ) { $php_files = []; $files = (array) $decoded_body->files; foreach ( $files as $filename => $file_data ) { - if ( is_object( $file_data ) && isset( $file_data->raw_url ) && pathinfo( $filename, PATHINFO_EXTENSION ) === 'php' ) { + if ( is_object( $file_data ) && isset( $file_data->raw_url ) && strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) ) === 'php' ) { $php_files[] = [ 'name' => $filename, 'raw_url' => $file_data->raw_url,