Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions features/core-download.feature
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,74 @@ Feature: Download WordPress
Then the wp-settings.php file should exist
And the {SUITE_CACHE_DIR}/core/wordpress-{VERSION}-de_DE.tar.gz file should exist

Scenario: Error when requested locale is not available for the latest version
Given an empty directory
And an empty cache
And that HTTP requests to https://api.wordpress.org/core/version-check/1.7/ will respond with:
"""
HTTP/1.1 200 OK
Content-Type: application/json

{"offers":[{"response":"upgrade","download":"https://downloads.wordpress.org/release/wordpress-6.9.4.zip","locale":"en_US","packages":{"full":"https://downloads.wordpress.org/release/wordpress-6.9.4.zip","no_content":"https://downloads.wordpress.org/release/wordpress-6.9.4-no-content.zip","new_bundled":"https://downloads.wordpress.org/release/wordpress-6.9.4-new-bundled.zip","partial":false,"rollback":false},"current":"6.9.4","version":"6.9.4","php_version":"7.2.24","mysql_version":"5.5.5","new_bundled":"6.7","partial_version":false}]}
"""

When I try `wp core download --locale=de_DE`
Then the return code should be 1
And STDERR should contain:
"""
Error: The requested locale (de_DE) was not found.
"""

Scenario: Download older locale version when latest is not yet available using --skip-locale-check
Given an empty directory
And an empty cache
And that HTTP requests to https://api.wordpress.org/core/version-check/1.7/ will respond with:
"""
HTTP/1.1 200 OK
Content-Type: application/json

{"offers":[{"response":"upgrade","download":"https://downloads.wordpress.org/release/wordpress-6.9.4.zip","locale":"en_US","packages":{"full":"https://downloads.wordpress.org/release/wordpress-6.9.4.zip","no_content":"https://downloads.wordpress.org/release/wordpress-6.9.4-no-content.zip","new_bundled":"https://downloads.wordpress.org/release/wordpress-6.9.4-new-bundled.zip","partial":false,"rollback":false},"current":"6.9.4","version":"6.9.4","php_version":"7.2.24","mysql_version":"5.5.5","new_bundled":"6.7","partial_version":false}]}
"""
And that HTTP requests to https://api.wordpress.org/translations/core/1.0/ will respond with:
"""
HTTP/1.1 200 OK
Content-Type: application/json

{"translations":[{"language":"de_DE","version":"4.4.2","updated":"2024-01-01 00:00:00","english_name":"German","native_name":"Deutsch","package":"https://downloads.wordpress.org/translation/core/4.4.2/de_DE.zip"}]}
"""

When I try `wp core download --locale=de_DE --skip-locale-check`
Then the wp-settings.php file should exist
And STDERR should contain:
"""
Warning: The latest WordPress version is not yet available in the de_DE locale. Downloading version 4.4.2 instead.
"""
Comment on lines +90 to +95
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This scenario expects a successful download, but it uses When I try ... and doesn’t assert a zero exit code. To make the test reliably fail when the command fails, use When I run ... (or explicitly assert Then the return code should be 0).

Copilot uses AI. Check for mistakes.

Scenario: Error when --skip-locale-check is set but no translation exists for locale
Given an empty directory
And an empty cache
And that HTTP requests to https://api.wordpress.org/core/version-check/1.7/ will respond with:
"""
HTTP/1.1 200 OK
Content-Type: application/json

{"offers":[{"response":"upgrade","download":"https://downloads.wordpress.org/release/wordpress-6.9.4.zip","locale":"en_US","packages":{"full":"https://downloads.wordpress.org/release/wordpress-6.9.4.zip","no_content":"https://downloads.wordpress.org/release/wordpress-6.9.4-no-content.zip","new_bundled":"https://downloads.wordpress.org/release/wordpress-6.9.4-new-bundled.zip","partial":false,"rollback":false},"current":"6.9.4","version":"6.9.4","php_version":"7.2.24","mysql_version":"5.5.5","new_bundled":"6.7","partial_version":false}]}
"""
And that HTTP requests to https://api.wordpress.org/translations/core/1.0/ will respond with:
"""
HTTP/1.1 200 OK
Content-Type: application/json

{"translations":[]}
"""

When I try `wp core download --locale=de_DE --skip-locale-check`
Then the return code should be 1
And STDERR should contain:
"""
Error: The requested locale (de_DE) was not found.
"""

Scenario: Catch download of non-existent WP version
Given an empty directory

Expand Down
85 changes: 74 additions & 11 deletions src/Core_Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
use WP_CLI\Extractor;
use WP_CLI\Iterators\Table as TableIterator;
use WP_CLI\Utils;
use WP_CLI\Path;
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use WP_CLI\Path; introduces a WP_CLI\Path dependency, but this package doesn’t define that class anywhere (and the rest of this file uses the WP_CLI\Utils\… function helpers). Unless WP_CLI\Path is guaranteed to exist in the supported wp-cli/wp-cli versions, this will cause a fatal error. Consider reverting these changes back to Utils\trailingslashit() / Utils\phar_safe_path() (or importing the correct helper namespace/class if a Path helper is intended).

Suggested change
use WP_CLI\Path;

Copilot uses AI. Check for mistakes.
use WP_CLI\Formatter;
use WP_CLI\Loggers;
use WP_CLI\WpOrgApi;
Expand Down Expand Up @@ -149,6 +150,9 @@ public function check_update( $args, $assoc_args ) {
* [--extract]
* : Whether to extract the downloaded file. Defaults to true.
*
* [--skip-locale-check]
* : If specified, allows downloading an older version of WordPress when the requested locale is not available for the latest release.
*
* ## EXAMPLES
*
* $ wp core download --locale=nl_NL
Expand All @@ -160,7 +164,7 @@ public function check_update( $args, $assoc_args ) {
* @when before_wp_load
*
* @param array{0?: string} $args Positional arguments.
* @param array{path?: string, locale?: string, version?: string, 'skip-content'?: bool, force?: bool, insecure?: bool, extract?: bool} $assoc_args Associative arguments.
* @param array{path?: string, locale?: string, version?: string, 'skip-content'?: bool, force?: bool, insecure?: bool, extract?: bool, 'skip-locale-check'?: bool} $assoc_args Associative arguments.
*/
public function download( $args, $assoc_args ) {
/**
Expand Down Expand Up @@ -233,14 +237,22 @@ public function download( $args, $assoc_args ) {

$download_url = $this->get_download_url( $version, $locale, $extension );
} else {
$wp_org_api = new WpOrgApi( [ 'insecure' => $insecure ] );
try {
$offer = ( new WpOrgApi( [ 'insecure' => $insecure ] ) )
->get_core_download_offer( $locale );
$offer = $wp_org_api->get_core_download_offer( $locale );
} catch ( Exception $exception ) {
WP_CLI::error( $exception );
}
if ( ! $offer ) {
WP_CLI::error( "The requested locale ({$locale}) was not found." );
if ( Utils\get_flag_value( $assoc_args, 'skip-locale-check', false ) ) {
$offer = $this->find_latest_offer_for_locale( $locale, $insecure );
if ( is_array( $offer ) ) {
WP_CLI::warning( "The latest WordPress version is not yet available in the {$locale} locale. Downloading version {$offer['current']} instead." );
}
}
if ( ! $offer ) {
WP_CLI::error( "The requested locale ({$locale}) was not found." );
}
}
$version = $offer['current'];
$download_url = $offer['download'];
Expand Down Expand Up @@ -703,8 +715,8 @@ private function set_server_url_vars( $url ) {
$_SERVER['SCRIPT_NAME'] = $path;

// Set SCRIPT_FILENAME to the actual WordPress index.php if available.
if ( file_exists( Utils\trailingslashit( ABSPATH ) . 'index.php' ) ) {
$_SERVER['SCRIPT_FILENAME'] = Utils\trailingslashit( ABSPATH ) . 'index.php';
if ( file_exists( Path::trailingslashit( ABSPATH ) . 'index.php' ) ) {
$_SERVER['SCRIPT_FILENAME'] = Path::trailingslashit( ABSPATH ) . 'index.php';
}
Comment on lines 717 to 720
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Path::trailingslashit() is used here, but this file previously relied on the Utils\trailingslashit() function. If WP_CLI\Path is not available (or doesn’t provide trailingslashit()), this will fatal at runtime. Please keep this consistent with the rest of the command by using the existing Utils\trailingslashit() helper (or confirm the correct Path helper import and API).

Copilot uses AI. Check for mistakes.
}

Expand Down Expand Up @@ -1063,7 +1075,7 @@ private static function get_wp_details( $abspath = ABSPATH ) {
* Gets the template path based on installation type.
*/
private static function get_template_path( $template ) {
$command_root = Utils\phar_safe_path( dirname( __DIR__ ) );
$command_root = Path::phar_safe( dirname( __DIR__ ) );
$template_path = "{$command_root}/templates/{$template}";

Comment on lines 1077 to 1080
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Path::phar_safe() appears to replace the existing Utils\phar_safe_path() helper. If WP_CLI\Path/phar_safe() isn’t part of the supported WP-CLI API, this will break template resolution at runtime. Consider reverting to Utils\phar_safe_path() or updating to the correct, verified helper name/namespace.

Copilot uses AI. Check for mistakes.
if ( ! file_exists( $template_path ) ) {
Expand Down Expand Up @@ -1671,6 +1683,57 @@ private function get_download_url( $version, $locale = 'en_US', $file_type = 'zi
return "https://{$locale_subdomain}wordpress.org/wordpress-{$version}{$locale_suffix}.{$file_type}";
}

/**
* Finds the latest available WordPress download offer for a given locale by consulting
* the WordPress.org translations API.
*
* Used as a fallback when the primary version-check API does not return an offer for
* the requested locale (e.g., when a new WordPress release hasn't been translated yet).
*
* @param string $locale Locale to find an offer for.
* @param bool $insecure Whether to disable SSL verification.
* @return array{current: string, download: string}|false Offer array on success, false on failure.
*/
private function find_latest_offer_for_locale( $locale, $insecure ) {
$headers = [ 'Accept' => 'application/json' ];
$options = [
'timeout' => 30,
'insecure' => $insecure,
];

try {
/** @var \WpOrg\Requests\Response $response */
$response = Utils\http_request( 'GET', 'https://api.wordpress.org/translations/core/1.0/', null, $headers, $options );
} catch ( Exception $exception ) {
return false;
}

if ( $response->status_code < 200 || $response->status_code >= 300 ) {
return false;
}

/** @var array{translations: array<int, array{language: string, version: string}>}|null $body */
$body = json_decode( $response->body, true );

if ( ! is_array( $body ) || empty( $body['translations'] ) ) {
return false;
}

foreach ( $body['translations'] as $translation ) {
if (
isset( $translation['language'], $translation['version'] )
&& $locale === $translation['language']
) {
return [
'current' => $translation['version'],
'download' => $this->get_download_url( $translation['version'], $locale, 'zip' ),
];
}
}

return false;
}

/**
* Returns update information.
*
Expand Down Expand Up @@ -2027,7 +2090,7 @@ private function remove_old_files_from_list( $files ) {
WP_CLI::debug( 'Failed to resolve ABSPATH realpath', 'core' );
return $count;
}
$abspath_realpath_trailing = Utils\trailingslashit( $abspath_realpath );
$abspath_realpath_trailing = Path::trailingslashit( $abspath_realpath );

Comment on lines 2092 to 2094
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same Path::trailingslashit() concern applies here: if WP_CLI\Path isn’t available (or doesn’t implement trailingslashit()), file cleanup will fatal. Using the existing Utils\trailingslashit() helper keeps this consistent with the rest of the codebase and avoids introducing an unverified dependency.

Copilot uses AI. Check for mistakes.
foreach ( $files as $file ) {
$file_path = ABSPATH . $file;
Expand All @@ -2041,7 +2104,7 @@ private function remove_old_files_from_list( $files ) {
if ( is_link( $file_path ) ) {
$normalized_path = realpath( dirname( $file_path ) );
if ( false === $normalized_path
|| 0 !== strpos( Utils\trailingslashit( $normalized_path ), $abspath_realpath_trailing )
|| 0 !== strpos( Path::trailingslashit( $normalized_path ), $abspath_realpath_trailing )
) {
WP_CLI::debug( "Skipping symbolic link outside of ABSPATH: {$file}", 'core' );
continue;
Expand All @@ -2057,7 +2120,7 @@ private function remove_old_files_from_list( $files ) {

// Regular files/directories: validate real path is within ABSPATH.
$file_realpath = realpath( $file_path );
if ( false === $file_realpath || 0 !== strpos( Utils\trailingslashit( $file_realpath ), $abspath_realpath_trailing ) ) {
if ( false === $file_realpath || 0 !== strpos( Path::trailingslashit( $file_realpath ), $abspath_realpath_trailing ) ) {
WP_CLI::debug( "Skipping file outside of ABSPATH: {$file}", 'core' );
continue;
}
Expand Down Expand Up @@ -2093,7 +2156,7 @@ private function remove_directory( $dir, $abspath_realpath_trailing ) {
WP_CLI::debug( "Failed to resolve realpath for directory: {$dir}", 'core' );
return false;
}
if ( 0 !== strpos( Utils\trailingslashit( $dir_realpath ), $abspath_realpath_trailing ) ) {
if ( 0 !== strpos( Path::trailingslashit( $dir_realpath ), $abspath_realpath_trailing ) ) {
WP_CLI::debug( "Attempted to remove directory outside of ABSPATH: {$dir_realpath}", 'core' );
return false;
}
Expand Down
Loading