From 2ef13461ce7a91eae35140f1e25987d6e74ce407 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Fri, 11 Sep 2026 18:12:25 +0200 Subject: [PATCH 01/10] Drive Composer as a child process instead of embedding it `wp package` no longer requires composer/composer. On first use it downloads composer.phar from getcomposer.org into the WP-CLI cache, verifies the SHA-256, and runs it with --working-dir pointing at the packages directory. Composer's output is relayed through WP_CLI::log(). WP_CLI_COMPOSER_BINARY points at an existing Composer instead; offline the newest cached Phar is used. install/update/uninstall run `composer update --prefer-source`, the same operation Installer::setUpdate(true) performed. list/get read vendor/composer/installed.json and check updates with one `composer outdated --direct --format=json` call. browse reads the package index JSON directly. JsonManipulator no longer imports Composer. This removes Composer's dependency tree (symfony/*, psr/*, react, seld/*, json-schema) from the Phar, where it collides with site code. See wp-cli/wp-cli#4632 and wp-cli/wp-cli#5920. --- composer.json | 1 - src/Package_Command.php | 374 +++++------------------ src/WP_CLI/JsonManipulator.php | 71 +++-- src/WP_CLI/Package/ComposerIO.php | 45 --- src/WP_CLI/Package/ComposerPhar.php | 258 ++++++++++++++++ src/WP_CLI/Package/InstalledPackages.php | 60 ++++ src/WP_CLI/Package/PackageIndex.php | 66 ++++ 7 files changed, 513 insertions(+), 362 deletions(-) delete mode 100644 src/WP_CLI/Package/ComposerIO.php create mode 100644 src/WP_CLI/Package/ComposerPhar.php create mode 100644 src/WP_CLI/Package/InstalledPackages.php create mode 100644 src/WP_CLI/Package/PackageIndex.php diff --git a/composer.json b/composer.json index f52bd940..0a5c4d92 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,6 @@ ], "require": { "ext-json": "*", - "composer/composer": "^2.10.2", "wp-cli/wp-cli": "^3.0" }, "require-dev": { diff --git a/src/Package_Command.php b/src/Package_Command.php index 81621306..02748de3 100644 --- a/src/Package_Command.php +++ b/src/Package_Command.php @@ -1,27 +1,12 @@ get_package_by_shortened_identifier( $package_name ); + $package = $this->get_package_by_shortened_identifier( $package_name, $insecure ); if ( ! $package ) { WP_CLI::error( sprintf( "Invalid package: shortened identifier '%s' not found.", $package_name ) ); } @@ -350,10 +337,9 @@ public function install( $args, $assoc_args ) { $version = $this->resolve_stable_version( $package_name, $insecure ); } $package_name = $this->check_github_package_name( $package_name, $version, $insecure ); + } else { + $package_name = $package; } - } elseif ( $package_name !== $package->getPrettyName() ) { - // BC support for specifying lowercase names for mixed-case package index packages - don't bother warning. - $package_name = $package->getPrettyName(); } } @@ -420,22 +406,12 @@ public function install( $args, $assoc_args ) { } file_put_contents( $json_path, $json_manipulator->getContents() ); - $composer = $this->get_composer(); - - // Set up the EventSubscriber - $event_subscriber = new PackageManagerEventSubscriber(); - $composer->getEventDispatcher()->addSubscriber( $event_subscriber ); - // Set up the installer - $install = Installer::create( new ComposerIO(), $composer ); - $install->setUpdate( true ); // Installer class will only override composer.lock with this flag - $install->setPreferSource( true ); // Use VCS when VCS for easier contributions. - // Try running the installer, but revert composer.json if failed WP_CLI::log( 'Using Composer to install the package...' ); WP_CLI::log( '---' ); - $res = false; + $res = 1; try { - $res = $install->run(); + $res = ( new ComposerPhar( $insecure ) )->run( [ 'update', '--prefer-source' ], dirname( $json_path ) ); } catch ( Exception $e ) { WP_CLI::warning( $e->getMessage() ); } @@ -618,32 +594,7 @@ public function get( $args, $assoc_args ) { } $skip_update_check = Utils\get_flag_value( $assoc_args, 'skip-update-check', false ); - $composer = $this->get_composer(); - - $package_output = []; - $package_output['name'] = $package->getPrettyName(); - $package_output['description'] = $package->getDescription(); - $package_output['authors'] = implode( ', ', array_column( (array) $package->getAuthors(), 'name' ) ); - $package_output['version'] = $package->getPrettyVersion(); - $update = 'none'; - $update_version = ''; - - if ( ! $skip_update_check ) { - try { - $latest = $this->find_latest_package( $package, $composer ); - if ( $latest && $latest->getFullPrettyVersion() !== $package->getFullPrettyVersion() ) { - $update = 'available'; - $update_version = $latest->getPrettyVersion(); - } - } catch ( Exception $e ) { - WP_CLI::warning( $e->getMessage() ); - $update = 'error'; - $update_version = $update; - } - } - - $package_output['update'] = $update; - $package_output['update_version'] = $update_version; + $package_output = InstalledPackages::with_update( $package, $skip_update_check ? [] : $this->get_outdated_packages() ); $default_fields = [ 'name', @@ -721,44 +672,26 @@ public function update( $args, $assoc_args = [] ) { WP_CLI::error( sprintf( "Package '%s' is not installed.", $package_name ) ); } // Use the package's pretty name (case-sensitive) from composer - $packages_to_update[] = $package->getPrettyName(); + $packages_to_update[] = $package['name']; } } - $composer = $this->get_composer(); - - // Set up the EventSubscriber with tracking for updates + $this->get_installed_packages(); // Validate composer.json before starting the child. + $packages_dir = dirname( $this->get_composer_json_path() ); + $installed_path = $packages_dir . '/vendor/composer/installed.json'; + $before = InstalledPackages::read( $installed_path ); $updated_packages = []; - $event_subscriber = new PackageManagerEventSubscriber(); - $composer->getEventDispatcher()->addSubscriber( $event_subscriber ); - - // Add a listener to track actual package updates - $composer->getEventDispatcher()->addListener( - 'post-package-update', - function ( $event ) use ( &$updated_packages ) { - $operation = $event->getOperation(); - if ( method_exists( $operation, 'getTargetPackage' ) ) { - $package = $operation->getTargetPackage(); - $updated_packages[] = $package->getPrettyName(); - } - } - ); - - // Set up the installer - $install = Installer::create( new ComposerIO(), $composer ); - $install->setUpdate( true ); // Installer class will only override composer.lock with this flag - $install->setPreferSource( true ); // Use VCS when VCS for easier contributions. - - // If specific packages are provided, use the allow list - if ( ! empty( $packages_to_update ) ) { - $install->setUpdateAllowList( $packages_to_update ); - } WP_CLI::log( 'Using Composer to update packages...' ); WP_CLI::log( '---' ); - $res = false; + $res = 1; try { - $res = $install->run(); + $res = ( new ComposerPhar() )->run( array_merge( [ 'update' ], $packages_to_update, [ '--prefer-source' ] ), $packages_dir ); + foreach ( InstalledPackages::read( $installed_path ) as $name => $package ) { + if ( isset( $before[ $name ] ) && ( $before[ $name ]['version'] !== $package['version'] || $before[ $name ]['source_reference'] !== $package['source_reference'] ) ) { + $updated_packages[] = $name; + } + } } catch ( Exception $e ) { WP_CLI::warning( $e->getMessage() ); } @@ -770,7 +703,7 @@ function ( $event ) use ( &$updated_packages ) { $num_packages = count( $packages_to_update ); if ( $num_packages > 0 ) { // When specific packages were requested, report on actual updates - $num_updated = count( $updated_packages ); + $num_updated = count( array_intersect( $packages_to_update, $updated_packages ) ); if ( 0 === $num_updated ) { if ( 1 === $num_packages ) { WP_CLI::success( 'Package already at latest version.' ); @@ -831,7 +764,7 @@ public function uninstall( $args, $assoc_args ) { $this->set_composer_auth_env_var(); $package = $this->get_installed_package_by_name( $package_name ); if ( false === $package ) { - $package_name = $this->get_package_by_shortened_identifier( $package_name ); + $package_name = $this->get_package_by_shortened_identifier( $package_name, $insecure ); if ( false === $package_name ) { WP_CLI::error( 'Package not installed.' ); } @@ -841,7 +774,7 @@ public function uninstall( $args, $assoc_args ) { $package_name = $this->check_git_package_name( $matches['repo_name'], $package_name, $version, $insecure ); } } else { - $package_name = $package->getPrettyName(); // Make sure package name is what's in composer.json. + $package_name = $package['name']; // Make sure package name is what's in composer.json. } // Read the WP-CLI packages composer.json and do some initial error checking. @@ -861,17 +794,10 @@ public function uninstall( $args, $assoc_args ) { $manipulator->removeSubNode( 'repositories', $package_name, true /*caseInsensitive*/ ); file_put_contents( $json_path, $manipulator->getContents() ); - $composer = $this->get_composer(); - - // Set up the installer. - $install = Installer::create( new NullIO(), $composer ); - $install->setUpdate( true ); // Installer class will only override composer.lock with this flag - $install->setPreferSource( true ); // Use VCS when VCS for easier contributions. - WP_CLI::log( 'Removing package directories and regenerating autoloader...' ); - $res = false; + $res = 1; try { - $res = $install->run(); + $res = ( new ComposerPhar( $insecure ) )->run( [ 'update', '--prefer-source' ], dirname( $json_path ), true ); } catch ( Exception $e ) { WP_CLI::warning( $e->getMessage() ); } @@ -909,43 +835,17 @@ public function is_installed( $args, $assoc_args ) { WP_CLI::halt( $this->get_installed_package_by_name( $package_name ) ? 0 : 1 ); } - /** - * Gets a Composer instance. - */ - private function get_composer() { - $this->avoid_composer_ca_bundle(); - try { - $composer_path = $this->get_composer_json_path(); - - // Composer's auto-load generating code makes some assumptions about where - // the 'vendor-dir' is, and where Composer is running from. - // Best to just pretend we're installing a package from ~/.wp-cli or similar - chdir( pathinfo( $composer_path, PATHINFO_DIRNAME ) ); - - // Prevent DateTime error/warning when no timezone set. - // Note: The package is loaded before WordPress load, For environments that don't have set time in php.ini. - // phpcs:ignore WordPress.DateTime.RestrictedFunctions.timezone_change_date_default_timezone_set,WordPress.PHP.NoSilencedErrors.Discouraged - date_default_timezone_set( @date_default_timezone_get() ); - - $composer = Factory::create( new NullIO(), $composer_path ); - } catch ( Exception $e ) { - WP_CLI::error( sprintf( 'Failed to get composer instance: %s', $e->getMessage() ) ); - } - return $composer; - } - /** * Gets all of the community packages. * * @return array */ - private function get_community_packages() { + private function get_community_packages( $insecure = false ) { static $community_packages; if ( null === $community_packages ) { - $this->avoid_composer_ca_bundle(); try { - $community_packages = $this->package_index()->getPackages(); + $community_packages = ( new PackageIndex( $insecure ) )->packages(); } catch ( Exception $e ) { WP_CLI::error( $e->getMessage() ); } @@ -954,40 +854,6 @@ private function get_community_packages() { return $community_packages; } - /** - * Gets the package index instance - * - * We need to construct the instance manually, because there's no way to select - * a particular instance using $composer->getRepositoryManager() - * - * @return ComposerRepository - */ - private function package_index() { - static $package_index; - - if ( ! $package_index ) { - $config_args = [ - 'config' => [ - 'secure-http' => true, - 'home' => dirname( $this->get_composer_json_path() ), - ], - ]; - $config = new Config(); - $config->merge( $config_args ); - $config->setConfigSource( new JsonConfigSource( $this->get_composer_json() ) ); - - $io = new NullIO(); - try { - $http_downloader = new HttpDownloader( $io, $config ); - $package_index = new ComposerRepository( [ 'url' => self::PACKAGE_INDEX_URL ], $io, $config, $http_downloader ); - } catch ( Exception $e ) { - WP_CLI::error( $e->getMessage() ); - } - } - - return $package_index; - } - /** * Displays a set of packages * @@ -1020,48 +886,17 @@ private function show_packages( $context, $packages, $assoc_args ) { $assoc_args = array_merge( $defaults, $assoc_args ); $skip_update_check = Utils\get_flag_value( $assoc_args, 'skip-update-check', false ); - $composer = $this->get_composer(); + $outdated = 'list' === $context && ! $skip_update_check && $packages ? $this->get_outdated_packages() : []; $list = []; foreach ( $packages as $package ) { - $name = $package->getPrettyName(); - if ( isset( $list[ $name ] ) ) { - $list[ $name ]['version'][] = $package->getPrettyVersion(); - } else { - $package_output = []; - $package_output['name'] = $package->getPrettyName(); - $package_output['description'] = $package->getDescription(); - $package_output['authors'] = implode( ', ', array_column( (array) $package->getAuthors(), 'name' ) ); - $package_output['version'] = [ $package->getPrettyVersion() ]; - $update = 'none'; - $update_version = ''; - if ( 'list' === $context && ! $skip_update_check ) { - try { - $latest = $this->find_latest_package( $package, $composer ); - if ( $latest && $latest->getFullPrettyVersion() !== $package->getFullPrettyVersion() ) { - $update = 'available'; - $update_version = $latest->getPrettyVersion(); - } - } catch ( Exception $e ) { - WP_CLI::warning( $e->getMessage() ); - $update = 'error'; - $update_version = $update; - } - } - $package_output['update'] = $update; - $package_output['update_version'] = $update_version; - $package_output['pretty_name'] = $package->getPrettyName(); // Deprecated but kept for BC with package-command 1.0.8. - $list[ $package_output['name'] ] = $package_output; + $package_output = InstalledPackages::with_update( $package, $outdated ); + if ( 'browse' === $context ) { + $package_output['version'] = implode( ', ', $package['versions'] ); } + $package_output['pretty_name'] = $package['name']; // Deprecated but kept for BC with package-command 1.0.8. + $list[ $package['name'] ] = $package_output; } - $list = array_map( - function ( $package ) { - $package['version'] = implode( ', ', $package['version'] ); - return $package; - }, - $list - ); - ksort( $list ); if ( 'ids' === $assoc_args['format'] ) { $list = array_keys( $list ); @@ -1069,6 +904,24 @@ function ( $package ) { Utils\format_items( $assoc_args['format'], $list, $assoc_args['fields'] ); } + /** + * Runs one update check for all direct dependencies. + * + * @return array|null Null on failure. + */ + private function get_outdated_packages() { + try { + $outdated = ( new ComposerPhar() )->run_json( [ 'outdated', '--direct', '--format=json' ], dirname( $this->get_composer_json_path() ) ); + if ( ! isset( $outdated['installed'] ) || ! is_array( $outdated['installed'] ) ) { + throw new Exception( 'Failed to check package updates: invalid Composer outdated response.' ); + } + return $outdated; + } catch ( Exception $e ) { + WP_CLI::warning( $e->getMessage() ); + return null; + } + } + /** * Gets a package by its shortened identifier. * @@ -1084,13 +937,13 @@ function ( $package ) { private function get_package_by_shortened_identifier( $package_name, $insecure = false ) { // Check the package index first, so we don't break existing behavior. $lc_package_name = strtolower( $package_name ); // For BC check. - foreach ( $this->get_community_packages() as $package ) { - if ( $package_name === $package->getPrettyName() ) { - return $package; + foreach ( $this->get_community_packages( $insecure ) as $package ) { + if ( $package_name === $package['name'] ) { + return $package['name']; } // For BC allow getting by lowercase name. - if ( $lc_package_name === $package->getName() ) { - return $package; + if ( strtolower( $package['name'] ) === $lc_package_name ) { + return $package['name']; } } @@ -1128,10 +981,12 @@ private function get_package_by_shortened_identifier( $package_name, $insecure = * Gets the installed community packages. */ private function get_installed_packages() { - $composer = $this->get_composer(); + $path = $this->get_composer_json_path(); + $existing = json_decode( file_get_contents( $path ), true ); + if ( ! is_array( $existing ) ) { + WP_CLI::error( 'Failed to get composer instance: Parse error in ' . $path . ': ' . json_last_error_msg() ); + } - $repo = $composer->getRepositoryManager()->getLocalRepository(); - $existing = json_decode( file_get_contents( $this->get_composer_json_path() ), true ); $installed_package_keys = ! empty( $existing['require'] ) ? array_keys( $existing['require'] ) : []; if ( empty( $installed_package_keys ) ) { return []; @@ -1139,10 +994,10 @@ private function get_installed_packages() { // For use by legacy incorrect name check. $lc_installed_package_keys = array_map( 'strtolower', $installed_package_keys ); $installed_packages = []; - foreach ( $repo->getCanonicalPackages() as $package ) { - $idx = array_search( $package->getName(), $lc_installed_package_keys, true ); + foreach ( InstalledPackages::read( dirname( $path ) . '/vendor/composer/installed.json' ) as $package ) { + $idx = array_search( strtolower( $package['name'] ), $lc_installed_package_keys, true ); // Use pretty name as it's case sensitive and what's in composer.json (or at least should be). - if ( in_array( $package->getPrettyName(), $installed_package_keys, true ) ) { + if ( in_array( $package['name'], $installed_package_keys, true ) ) { $installed_packages[] = $package; } elseif ( false !== $idx ) { // Legacy incorrect name check. $installed_packages[] = $package; @@ -1156,11 +1011,11 @@ private function get_installed_packages() { */ private function get_installed_package_by_name( $package_name ) { foreach ( $this->get_installed_packages() as $package ) { - if ( $package_name === $package->getPrettyName() ) { + if ( $package_name === $package['name'] ) { return $package; } // Also check non-pretty (lowercase) name in case of legacy incorrect name. - if ( $package_name === $package->getName() ) { + if ( strtolower( $package['name'] ) === $package_name ) { return $package; } } @@ -1186,7 +1041,7 @@ private static function get_package_name_and_version_from_dir_package( $dir_pack WP_CLI::error( sprintf( "Invalid package: no name in composer.json file '%s'.", $composer_file ) ); } $package_name = $composer_data['name']; - $naming_error = ValidatingArrayLoader::hasPackageNamingError( $package_name ); + $naming_error = InstalledPackages::has_naming_error( $package_name ); if ( null !== $naming_error ) { WP_CLI::error( sprintf( "Invalid package name '%s': %s", $package_name, $naming_error ) ); } @@ -1237,13 +1092,6 @@ private static function resolve_dot_segments( $path ) { return $is_absolute ? '/' . $resolved : $resolved; } - /** - * Gets the WP-CLI packages composer.json object. - */ - private function get_composer_json() { - return new JsonFile( $this->get_composer_json_path() ); - } - /** * Gets the absolute path to the WP-CLI packages composer.json. */ @@ -1293,8 +1141,6 @@ private function create_default_composer_json( $composer_path ) { $composer_path = Path::trailingslashit( $composer_dir ) . Path::basename( $composer_path ); - $json_file = new JsonFile( $composer_path ); - $repositories = (object) [ 'wp-cli' => (object) $this->composer_type_package, ]; @@ -1314,7 +1160,9 @@ private function create_default_composer_json( $composer_path ) { ]; try { - $json_file->write( $options ); + if ( false === file_put_contents( $composer_path, json_encode( $options, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n" ) ) { + throw new Exception( "Failed to write {$composer_path}." ); + } } catch ( Exception $e ) { WP_CLI::error( $e->getMessage() ); } @@ -1322,55 +1170,6 @@ private function create_default_composer_json( $composer_path ) { return $composer_path; } - /** - * Given a package, this finds the latest package matching it - * - * @param PackageInterface $package - * @param Composer $composer - * @param bool $minor_only - * - * @return PackageInterface|false - */ - private function find_latest_package( PackageInterface $package, Composer $composer, $minor_only = false ) { - // Find the latest version allowed in this pool/repository set. - $name = $package->getPrettyName(); - $version_selector = $this->get_version_selector( $composer ); - $stability = $composer->getPackage()->getMinimumStability(); - $flags = $composer->getPackage()->getStabilityFlags(); - if ( isset( $flags[ $name ] ) ) { - $stability = array_search( $flags[ $name ], BasePackage::STABILITIES, true ); - } - $best_stability = $stability; - if ( $composer->getPackage()->getPreferStable() ) { - $best_stability = $package->getStability(); - } - $target_version = null; - if ( 0 === strpos( $package->getVersion(), 'dev-' ) ) { - $target_version = $package->getVersion(); - } - if ( null === $target_version && $minor_only ) { - $target_version = '^' . $package->getVersion(); - } - - return $version_selector->findBestCandidate( $name, $target_version, $best_stability ); - } - - /** - * @return VersionSelector - */ - private function get_version_selector( Composer $composer ) { - if ( ! $this->version_selector ) { - $repository_set = new Repository\RepositorySet( - $composer->getPackage()->getMinimumStability(), - $composer->getPackage()->getStabilityFlags() - ); - $repository_set->addRepository( new CompositeRepository( $composer->getRepositoryManager()->getRepositories() ) ); - $this->version_selector = new VersionSelector( $repository_set ); - } - - return $this->version_selector; - } - /** * Checks whether a given package is a git repository. * @@ -1625,7 +1424,7 @@ private function guess_version_constraint_from_tag( $tag ) { } /** - * Sets `COMPOSER_AUTH` environment variable (which Composer merges into the config setup in `Composer\Factory::createConfig()`) depending on available environment variables. + * Sets `COMPOSER_AUTH` environment variable (which Composer merges into the config setup when it starts) depending on available environment variables. * Avoids authorization failures when accessing various sites. */ private function set_composer_auth_env_var() { @@ -1690,32 +1489,23 @@ private function set_composer_auth_env_var() { } } - /** - * Avoid using default Composer CA bundle if in phar as we don't include it. - * See https://github.com/composer/ca-bundle/blob/1.1.0/src/CaBundle.php#L64 - */ - private function avoid_composer_ca_bundle() { - if ( Path::inside_phar() && ! getenv( 'SSL_CERT_FILE' ) && ! getenv( 'SSL_CERT_DIR' ) && ! ini_get( 'openssl.cafile' ) && ! ini_get( 'openssl.capath' ) ) { - $certificate_path = Utils\extract_from_phar( RequestsLibrary::get_bundled_certificate_path() ); - putenv( "SSL_CERT_FILE={$certificate_path}" ); - } - } - /** * Reads the WP-CLI packages composer.json, checking validity and returning array containing its path, contents, and decoded contents. * * @return array Indexed array containing the path, the contents, and the decoded contents of the WP-CLI packages composer.json. */ private function get_composer_json_path_backup_decoded() { - $composer_json_obj = $this->get_composer_json(); - $json_path = $composer_json_obj->getPath(); - $composer_backup = file_get_contents( $json_path ); + $json_path = $this->get_composer_json_path(); + $composer_backup = file_get_contents( $json_path ); if ( false === $composer_backup ) { $error = error_get_last(); WP_CLI::error( sprintf( "Failed to read '%s': %s", $json_path, $error['message'] ) ); } try { - $composer_backup_decoded = $composer_json_obj->read(); + $composer_backup_decoded = json_decode( $composer_backup, true ); + if ( ! is_array( $composer_backup_decoded ) ) { + throw new Exception( "Parse error in {$json_path}: " . json_last_error_msg() ); + } } catch ( Exception $e ) { WP_CLI::error( sprintf( "Failed to parse '%s' as json: %s", $json_path, $e->getMessage() ) ); } diff --git a/src/WP_CLI/JsonManipulator.php b/src/WP_CLI/JsonManipulator.php index 95df1699..76dca330 100644 --- a/src/WP_CLI/JsonManipulator.php +++ b/src/WP_CLI/JsonManipulator.php @@ -14,14 +14,37 @@ namespace WP_CLI; // WP_CLI -use Composer\Json\JsonFile; // WP_CLI -use Composer\Repository\PlatformRepository; /** * @author Jordi Boggiano */ class JsonManipulator { + // WP_CLI: begin + private static function parseJson($json) + { + $decoded = json_decode($json, true); + if (JSON_ERROR_NONE !== json_last_error()) { + throw new \InvalidArgumentException('Parse error: ' . json_last_error_msg()); + } + return $decoded; + } + + private static function encode($value) + { + $json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if (false === $json) { + throw new \InvalidArgumentException('Failed to encode JSON: ' . json_last_error_msg()); + } + return $json; + } + + private static function isPlatformPackage($name) + { + return (bool) preg_match('{^(?:php(?:-64bit|-ipv6|-zts|-debug)?|hhvm|(?:ext|lib)-[a-z0-9](?:[_.-]?[a-z0-9]+)*|composer(?:-(?:plugin|runtime)-api)?)$}iD', $name); + } + // WP_CLI: end + private static $DEFINES = '(?(DEFINE) (? -? (?= [1-9]|0(?!\d) ) \d+ (\.\d+)? ([eE] [+-]? \d+)? ) (? true | false | null ) @@ -57,7 +80,7 @@ public function getContents() public function addLink($type, $package, $constraint, $sortPackages = false, $caseInsensitive = false) // WP_CLI: caseInsensitive. { - $decoded = JsonFile::parseJson($this->contents); + $decoded = self::parseJson($this->contents); // no link of that type yet if (!isset($decoded[$type])) { @@ -65,7 +88,7 @@ public function addLink($type, $package, $constraint, $sortPackages = false, $ca } $regex = '{'.self::$DEFINES.'^(?P\s*\{\s*(?:(?&string)\s*:\s*(?&json)\s*,\s*)*?)'. - '(?P'.preg_quote(JsonFile::encode($type)).'\s*:\s*)(?P(?&json))(?P.*)}sx'; + '(?P'.preg_quote(self::encode($type)).'\s*:\s*)(?P(?&json))(?P.*)}sx'; if (!$this->pregMatch($regex, $this->contents, $matches)) { return false; } @@ -88,7 +111,7 @@ public function addLink($type, $package, $constraint, $sortPackages = false, $ca $existingPackage = $packageMatches['package']; $packageRegex = str_replace('/', '\\\\?/', preg_quote($existingPackage)); $links = preg_replace_callback('{'.self::$DEFINES.'"'.$packageRegex.'"(?P\s*:\s*)(?&string)}ix', function ($m) use ($existingPackage, $constraint) { - return JsonFile::encode(str_replace('\\/', '/', $existingPackage)) . $m['separator'] . '"' . $constraint . '"'; + return self::encode(str_replace('\\/', '/', $existingPackage)) . $m['separator'] . '"' . $constraint . '"'; }, $links); } else { if ($this->pregMatch('#^\s*\{\s*\S+.*?(\s*\}\s*)$#s', $links, $match)) { @@ -96,13 +119,13 @@ public function addLink($type, $package, $constraint, $sortPackages = false, $ca $links = preg_replace( '{'.preg_quote($match[1]).'$}', // addcslashes is used to double up backslashes/$ since preg_replace resolves them as back references otherwise, see #1588 - addcslashes(',' . $this->newline . $this->indent . $this->indent . JsonFile::encode($package).': '.JsonFile::encode($constraint) . $match[1], '\\$'), + addcslashes(',' . $this->newline . $this->indent . $this->indent . self::encode($package).': '.self::encode($constraint) . $match[1], '\\$'), $links ); } else { // links empty $links = '{' . $this->newline . - $this->indent . $this->indent . JsonFile::encode($package).': '.JsonFile::encode($constraint) . $this->newline . + $this->indent . $this->indent . self::encode($package).': '.self::encode($constraint) . $this->newline . $this->indent . '}'; } } @@ -128,7 +151,7 @@ public function addLink($type, $package, $constraint, $sortPackages = false, $ca private function sortPackages(array &$packages = array()) { $prefix = function ($requirement) { - if (PlatformRepository::isPlatformPackage($requirement)) { + if (self::isPlatformPackage($requirement)) { return preg_replace( array( '/^php/', @@ -196,7 +219,7 @@ public function removeProperty($name) public function addSubNode($mainNode, $name, $value, $caseInsensitive = false) // WP_CLI: caseInsensitive. { - $decoded = JsonFile::parseJson($this->contents); + $decoded = self::parseJson($this->contents); $subName = null; if (in_array($mainNode, array('config', 'extra')) && false !== strpos($name, '.')) { @@ -216,7 +239,7 @@ public function addSubNode($mainNode, $name, $value, $caseInsensitive = false) / // main node content not match-able $nodeRegex = '{'.self::$DEFINES.'^(?P \s* \{ \s* (?: (?&string) \s* : (?&json) \s* , \s* )*?'. - preg_quote(JsonFile::encode($mainNode)).'\s*:\s*)(?P(?&object))(?P.*)}sx'; + preg_quote(self::encode($mainNode)).'\s*:\s*)(?P(?&object))(?P.*)}sx'; try { if (!$this->pregMatch($nodeRegex, $this->contents, $match)) { @@ -276,7 +299,7 @@ public function addSubNode($mainNode, $name, $value, $caseInsensitive = false) / // child missing but non empty children $children = preg_replace( '#'.$whitespace.'}$#', - addcslashes(',' . $this->newline . $this->indent . $this->indent . JsonFile::encode($name).': '.$this->format($value, 1) . $whitespace . '}', '\\$'), + addcslashes(',' . $this->newline . $this->indent . $this->indent . self::encode($name).': '.$this->format($value, 1) . $whitespace . '}', '\\$'), $children ); } else { @@ -285,7 +308,7 @@ public function addSubNode($mainNode, $name, $value, $caseInsensitive = false) / } // children present but empty - $children = '{' . $this->newline . $this->indent . $this->indent . JsonFile::encode($name).': '.$this->format($value, 1) . $whitespace . '}'; + $children = '{' . $this->newline . $this->indent . $this->indent . self::encode($name).': '.$this->format($value, 1) . $whitespace . '}'; } } @@ -298,7 +321,7 @@ public function addSubNode($mainNode, $name, $value, $caseInsensitive = false) / public function removeSubNode($mainNode, $name, $caseInsensitive = false) // WP_CLI: caseInsensitive. { - $decoded = JsonFile::parseJson($this->contents); + $decoded = self::parseJson($this->contents); // no node or empty node if (empty($decoded[$mainNode])) { @@ -309,7 +332,7 @@ public function removeSubNode($mainNode, $name, $caseInsensitive = false) // WP_ if ( $caseInsensitive ) { // This is more or less a copy of the code at the start of `addLink()` above. $regex = '{'.self::$DEFINES.'^(?P\s*\{\s*(?:(?&string)\s*:\s*(?&json)\s*,\s*)*?)'. - '(?P'.preg_quote(JsonFile::encode($mainNode)).'\s*:\s*)(?P(?&json))(?P.*)}sx'; + '(?P'.preg_quote(self::encode($mainNode)).'\s*:\s*)(?P(?&json))(?P.*)}sx'; if (!$this->pregMatch($regex, $this->contents, $matches)) { return true; } @@ -332,7 +355,7 @@ public function removeSubNode($mainNode, $name, $caseInsensitive = false) // WP_ // no node content match-able $nodeRegex = '{'.self::$DEFINES.'^(?P \s* \{ \s* (?: (?&string) \s* : (?&json) \s* , \s* )*?'. - preg_quote(JsonFile::encode($mainNode)).'\s*:\s*)(?P(?&object))(?P.*)}sx'; + preg_quote(self::encode($mainNode)).'\s*:\s*)(?P(?&object))(?P.*)}sx'; try { if (!$this->pregMatch($nodeRegex, $this->contents, $match)) { return false; @@ -418,19 +441,19 @@ public function removeSubNode($mainNode, $name, $caseInsensitive = false) // WP_ public function addMainKey($key, $content) { - $decoded = JsonFile::parseJson($this->contents); + $decoded = self::parseJson($this->contents); $content = $this->format($content); // key exists already $regex = '{'.self::$DEFINES.'^(?P\s*\{\s*(?:(?&string)\s*:\s*(?&json)\s*,\s*)*?)'. - '(?P'.preg_quote(JsonFile::encode($key)).'\s*:\s*(?&json))(?P.*)}sx'; + '(?P'.preg_quote(self::encode($key)).'\s*:\s*(?&json))(?P.*)}sx'; if (isset($decoded[$key]) && $this->pregMatch($regex, $this->contents, $matches)) { // invalid match due to un-regexable content, abort if (!@json_decode('{'.$matches['key'].'}')) { return false; } - $this->contents = $matches['start'] . JsonFile::encode($key).': '.$content . $matches['end']; + $this->contents = $matches['start'] . self::encode($key).': '.$content . $matches['end']; return true; } @@ -439,7 +462,7 @@ public function addMainKey($key, $content) if ($this->pregMatch('#[^{\s](\s*)\}$#', $this->contents, $match)) { $this->contents = preg_replace( '#'.$match[1].'\}$#', - addcslashes(',' . $this->newline . $this->indent . JsonFile::encode($key). ': '. $content . $this->newline . '}', '\\$'), + addcslashes(',' . $this->newline . $this->indent . self::encode($key). ': '. $content . $this->newline . '}', '\\$'), $this->contents ); @@ -449,7 +472,7 @@ public function addMainKey($key, $content) // append at the end of the file $this->contents = preg_replace( '#\}$#', - addcslashes($this->indent . JsonFile::encode($key). ': '.$content . $this->newline . '}', '\\$'), + addcslashes($this->indent . self::encode($key). ': '.$content . $this->newline . '}', '\\$'), $this->contents ); @@ -458,7 +481,7 @@ public function addMainKey($key, $content) public function removeMainKey($key) { - $decoded = JsonFile::parseJson($this->contents); + $decoded = self::parseJson($this->contents); if (!isset($decoded[$key])) { return true; @@ -466,7 +489,7 @@ public function removeMainKey($key) // key exists already $regex = '{'.self::$DEFINES.'^(?P\s*\{\s*(?:(?&string)\s*:\s*(?&json)\s*,\s*)*?)'. - '(?P'.preg_quote(JsonFile::encode($key)).'\s*:\s*(?&json))\s*,?\s*(?P.*)}sx'; + '(?P'.preg_quote(self::encode($key)).'\s*:\s*(?&json))\s*,?\s*(?P.*)}sx'; if ($this->pregMatch($regex, $this->contents, $matches)) { // invalid match due to un-regexable content, abort if (!@json_decode('{'.$matches['removal'].'}')) { @@ -505,13 +528,13 @@ public function format($data, $depth = 0) $out = '{' . $this->newline; $elems = array(); foreach ($data as $key => $val) { - $elems[] = str_repeat($this->indent, $depth + 2) . JsonFile::encode($key). ': '.$this->format($val, $depth + 1); + $elems[] = str_repeat($this->indent, $depth + 2) . self::encode($key). ': '.$this->format($val, $depth + 1); } return $out . implode(','.$this->newline, $elems) . $this->newline . str_repeat($this->indent, $depth + 1) . '}'; } - return JsonFile::encode($data); + return self::encode($data); } protected function detectIndenting() diff --git a/src/WP_CLI/Package/ComposerIO.php b/src/WP_CLI/Package/ComposerIO.php deleted file mode 100644 index a89aaca9..00000000 --- a/src/WP_CLI/Package/ComposerIO.php +++ /dev/null @@ -1,45 +0,0 @@ -]+)>#', '$1$2', $messages ); - foreach ( $messages as $message ) { - // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags - WP_CLI::log( strip_tags( trim( $message ) ) ); - } - } -} diff --git a/src/WP_CLI/Package/ComposerPhar.php b/src/WP_CLI/Package/ComposerPhar.php new file mode 100644 index 00000000..430d4bf9 --- /dev/null +++ b/src/WP_CLI/Package/ComposerPhar.php @@ -0,0 +1,258 @@ +insecure = $insecure; + } + + /** + * @return string Path to the configured binary or verified, cached Phar. + */ + public function locate( $quiet = false ) { + $binary = getenv( 'WP_CLI_COMPOSER_BINARY' ); + if ( false !== $binary ) { + if ( ! is_file( $binary ) || ! is_readable( $binary ) ) { + throw new RuntimeException( "WP_CLI_COMPOSER_BINARY is not readable: {$binary}" ); + } + return realpath( $binary ); + } + if ( null !== $this->path ) { + return $this->path; + } + + $cache = WP_CLI::get_cache(); + $version = null; + try { + $versions = json_decode( $this->request( 'https://getcomposer.org/versions' )->body, true ); + foreach ( $versions['stable'] ?? [] as $release ) { + if ( preg_match( '/^2\.\d+\.\d+$/D', $release['version'] ) && $release['min-php'] <= PHP_VERSION_ID ) { + $version = $release['version']; + break; + } + } + } catch ( \Exception $e ) { + WP_CLI::debug( $e->getMessage(), 'packages' ); + } + if ( null === $version ) { + // Offline or getcomposer.org unreachable: reuse the newest Composer already in the cache. + $cached = self::cached_versions( $cache ); + if ( $cached ) { + $this->path = $cache->has( "composer/composer-{$cached[0]}.phar" ); + WP_CLI::debug( "Using cached Composer {$cached[0]}; version list unavailable.", 'packages' ); + return $this->path; + } + $version = 'latest-stable'; + } + + $key = "composer/composer-{$version}.phar"; + $path = $cache->has( $key ); + if ( $path ) { + $this->path = $path; + return $path; + } + + $temp_dir = Utils\get_temp_dir() . uniqid( 'wp-cli-composer-', true ); + if ( ! mkdir( $temp_dir, 0700 ) ) { + throw new RuntimeException( 'Could not create Composer download directory.' ); + } + $temp = $temp_dir . '/composer.phar'; + register_shutdown_function( + static function () use ( $temp, $temp_dir ) { + if ( file_exists( $temp ) ) { + unlink( $temp ); + } + rmdir( $temp_dir ); + } + ); + $path = $cache->is_enabled() ? $cache->get_root() . $key : $temp; + if ( ! $quiet ) { + WP_CLI::log( "Downloading Composer {$version} to {$path}..." ); + } + $url = "https://getcomposer.org/download/{$version}/composer.phar"; + $this->request( $url, [ 'filename' => $temp ] ); + $checksum = trim( $this->request( $url . '.sha256sum' )->body ); + if ( ! preg_match( '/^([a-f0-9]{64})(?:\s|$)/i', $checksum, $matches ) || ! hash_equals( strtolower( $matches[1] ), hash_file( 'sha256', $temp ) ) ) { + throw new RuntimeException( 'Composer download failed SHA-256 verification.' ); + } + chmod( $temp, 0755 ); + if ( $cache->is_enabled() ) { + if ( ! $cache->import( $key, $temp ) ) { + throw new RuntimeException( 'Could not cache Composer.' ); + } + chmod( $path, 0755 ); + } + $this->path = $path; + return $path; + } + + /** + * Composer versions present in the cache, newest first. + * + * @return string[] + */ + private static function cached_versions( $cache ) { + if ( ! $cache->is_enabled() ) { + return []; + } + $versions = []; + foreach ( glob( $cache->get_root() . 'composer/composer-*.phar' ) ?: [] as $file ) { + if ( preg_match( '/composer-(\d+\.\d+\.\d+)\.phar$/', $file, $matches ) ) { + $versions[] = $matches[1]; + } + } + usort( $versions, 'version_compare' ); + return array_reverse( $versions ); + } + + private function request( $url, $options = [] ) { + $response = Utils\http_request( + 'GET', + $url, + null, + [], + array_merge( + [ + 'timeout' => 600, + 'insecure' => $this->insecure, + 'halt_on_error' => false, + ], + $options + ) + ); + if ( $response->status_code < 200 || $response->status_code >= 300 ) { + throw new RuntimeException( "Could not download {$url} (HTTP code {$response->status_code})." ); + } + return $response; + } + + /** + * Builds a shell command, keeping each Composer argument separate. + * + * @return string + */ + public function command( array $args, $working_dir, $quiet = false ) { + $binary = $this->locate( $quiet ); + $prefix = ''; + if ( false === getenv( 'WP_CLI_COMPOSER_BINARY' ) || 'phar' === strtolower( pathinfo( $binary, PATHINFO_EXTENSION ) ) ) { + $prefix = Utils\esc_cmd( '%s', WP_CLI::get_php_binary() ) . ' '; + $php_args = getenv( 'WP_CLI_PHP_ARGS' ); + if ( false !== $php_args && '' !== $php_args ) { + $prefix .= $php_args . ' '; + } + } + $args[] = '--working-dir=' . $working_dir; + $args[] = '--no-interaction'; + $args[] = '--no-ansi'; + // Progress is an install/update option, not a global Composer option. + if ( in_array( $args[0], [ 'install', 'update', 'remove', 'require' ], true ) ) { + $args[] = '--no-progress'; + } + if ( $quiet ) { + $args[] = '--quiet'; + } + return $prefix . Utils\esc_cmd( '%s', $binary ) . ' ' . implode( ' ', array_map( 'escapeshellarg', $args ) ); + } + + /** + * @return int Composer's exit code. + */ + public function run( array $args, string $working_dir, bool $quiet = false ) { + list( $code ) = $this->execute( $args, $working_dir, $quiet, false ); + return $code; + } + + /** + * @return array Decoded JSON, or an exception when Composer fails. + */ + public function run_json( array $args, string $working_dir ) { + list( $code, $stdout ) = $this->execute( $args, $working_dir, false, true ); + $json = json_decode( $stdout, true ); + if ( 0 !== $code || ! is_array( $json ) ) { + throw new RuntimeException( "Failed to check package updates (Composer return code {$code}): invalid or unsuccessful JSON response." ); + } + return $json; + } + + /** + * Runs Composer and relays its output. Composer writes its progress to stderr; when streaming, both + * streams go through WP_CLI::log() so nothing reaches WP-CLI's own stderr. Reading two pipes can + * deadlock (non-blocking pipes are unsupported on Windows), so stderr is merged into stdout when + * streaming and parked in a file when stdout is captured as JSON. + * + * @return array{0:int,1:string} Exit code and captured stdout (empty unless $capture). + */ + private function execute( array $args, $working_dir, $quiet, $capture ) { + $command = $this->command( $args, $working_dir, $quiet ); + $env = getenv(); + $env['COMPOSER_NO_INTERACTION'] = '1'; + $stderr_file = $capture ? tempnam( Utils\get_temp_dir(), 'wp-cli-composer-' ) : null; + if ( ! $capture ) { + $command .= ' 2>&1'; // The shell merges the streams; only one pipe is read below. + } + $descriptors = [ + [ 'pipe', 'r' ], + [ 'pipe', 'w' ], + $capture ? [ 'file', $stderr_file, 'w' ] : [ 'pipe', 'w' ], + ]; + $process = Utils\proc_open_compat( $command, $descriptors, $pipes, $working_dir, $env ); + if ( ! is_resource( $process ) ) { + throw new RuntimeException( 'Could not start Composer.' ); + } + fclose( $pipes[0] ); + $stdout = ''; + $buffer = ''; + while ( ! feof( $pipes[1] ) ) { + $chunk = fread( $pipes[1], 8192 ); + if ( false === $chunk || '' === $chunk ) { + continue; + } + if ( $capture ) { + $stdout .= $chunk; + continue; + } + $buffer .= $chunk; + while ( preg_match( '/^(.*?)[\r\n]+/s', $buffer, $matches ) ) { + $this->output( $matches[1], $quiet, false ); + $buffer = substr( $buffer, strlen( $matches[0] ) ); + } + } + $this->output( $buffer, $quiet, false ); + fclose( $pipes[1] ); + if ( isset( $pipes[2] ) ) { + fclose( $pipes[2] ); + } + $code = proc_close( $process ); + if ( null !== $stderr_file ) { + foreach ( preg_split( '/[\r\n]+/', (string) file_get_contents( $stderr_file ) ) as $line ) { + $this->output( $line, $quiet, true ); + } + unlink( $stderr_file ); + } + return [ $code, $stdout ]; + } + + private function output( $line, $quiet, $debug ) { + $line = rtrim( preg_replace( '/\x1b\[[0-?]*[ -\/]*[@-~]/', '', $line ) ); + if ( $quiet || '' === $line ) { + return; + } + if ( $debug ) { + WP_CLI::debug( $line, 'packages' ); + } else { + WP_CLI::log( $line ); + } + } +} diff --git a/src/WP_CLI/Package/InstalledPackages.php b/src/WP_CLI/Package/InstalledPackages.php new file mode 100644 index 00000000..5657293e --- /dev/null +++ b/src/WP_CLI/Package/InstalledPackages.php @@ -0,0 +1,60 @@ + $name, + 'description' => $package['description'] ?? '', + 'authors' => implode( ', ', array_column( $package['authors'] ?? [], 'name' ) ), + 'version' => $version, + 'full_version' => $version . ( 0 === strpos( $version, 'dev-' ) && isset( $package['source']['reference'] ) ? ' ' . $package['source']['reference'] : '' ), + 'source_reference' => $package['source']['reference'] ?? '', + ]; + } + return $packages; + } + + public static function has_naming_error( $name ) { + if ( ! preg_match( '{^[a-z0-9]([_.-]?[a-z0-9]+)*/[a-z0-9](([_.]|-{1,2})?[a-z0-9]+)*$}iD', $name ) ) { + return 'The package name is invalid, it should have a vendor name, a forward slash, and a package name.'; + } + return null; + } + + /** + * @param array $package Installed metadata. + * @param array|null $outdated Composer output, or null if the check failed. + * @return array + */ + public static function with_update( array $package, $outdated ) { + $package['update'] = null === $outdated ? 'error' : 'none'; + $package['update_version'] = null === $outdated ? 'error' : ''; + foreach ( $outdated['installed'] ?? [] as $candidate ) { + if ( strtolower( $package['name'] ) === strtolower( $candidate['name'] ) && $candidate['latest'] !== $package['version'] ) { + $package['update'] = 'available'; + $package['update_version'] = $candidate['latest']; + break; + } + } + return $package; + } +} diff --git a/src/WP_CLI/Package/PackageIndex.php b/src/WP_CLI/Package/PackageIndex.php new file mode 100644 index 00000000..49e0d2d9 --- /dev/null +++ b/src/WP_CLI/Package/PackageIndex.php @@ -0,0 +1,66 @@ +insecure = $insecure; + } + + /** + * @return array Packages keyed by their original names. + */ + public function packages() { + $base = 'https://wp-cli.org/package-index/'; + $index = $this->fetch( $base . 'packages.json' ); + $packages = self::parse( $index ); + foreach ( $index['includes'] ?? [] as $file => $metadata ) { + $packages = array_replace( $packages, self::parse( $this->fetch( $base . $file ) ) ); + } + return $packages; + } + + private function fetch( $url ) { + $response = Utils\http_request( + 'GET', + $url, + null, + [], + [ + 'insecure' => $this->insecure, + 'halt_on_error' => false, + ] + ); + $data = json_decode( $response->body, true ); + if ( 200 !== $response->status_code || ! is_array( $data ) ) { + throw new RuntimeException( "Failed to read package index: {$url}" ); + } + return $data; + } + + /** + * @return array Packages in index order, with versions in index order. + */ + public static function parse( array $data ) { + $packages = []; + foreach ( $data['packages'] ?? [] as $name => $versions ) { + $first = reset( $versions ); + $packages[ $name ] = [ + 'name' => $first['name'] ?? $name, + 'description' => $first['description'] ?? '', + 'authors' => implode( ', ', array_column( $first['authors'] ?? [], 'name' ) ), + 'versions' => array_keys( $versions ), + ]; + } + return $packages; + } +} From 667fade12389ef461ec9785a522b4a06f1eddca1 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Fri, 11 Sep 2026 18:12:25 +0200 Subject: [PATCH 02/10] Tests for the Composer child process and metadata readers Unit tests cover command construction (WP_CLI_PHP_ARGS, WP_CLI_COMPOSER_BINARY as Phar or executable), output relay and failures, installed.json in Composer 1 and 2 shapes, package index parsing from a fixture, the package-name check and outdated mapping. The memory-limit revert scenario depended on Composer exhausting memory in-process; it now provokes a resolver failure and checks composer.json is byte-identical afterwards. Two scenarios cover the one-time download into the cache and WP_CLI_COMPOSER_BINARY. Git errors from Composer now appear on stdout, so the missing-repository assertions read stdout. --- features/package-install.feature | 2 +- features/package.feature | 120 ++++++++++++++++------ tests/phpunit/ComposerJsonTest.php | 25 +++++ tests/phpunit/ComposerPharTest.php | 112 ++++++++++++++++++++ tests/phpunit/PackageMetadataTest.php | 86 ++++++++++++++++ tests/phpunit/fixtures/package-index.json | 15 +++ 6 files changed, 329 insertions(+), 31 deletions(-) create mode 100644 tests/phpunit/ComposerPharTest.php create mode 100644 tests/phpunit/PackageMetadataTest.php create mode 100644 tests/phpunit/fixtures/package-index.json diff --git a/features/package-install.feature b/features/package-install.feature index 262e93d1..3e6a4e2d 100644 --- a/features/package-install.feature +++ b/features/package-install.feature @@ -1455,4 +1455,4 @@ Feature: Install WP-CLI packages Package installation failed """ # Git should report it couldn't authenticate, not prompt - And STDERR should match /fatal:|Could not read from remote repository|Repository not found/ + And STDOUT should match /fatal:|Could not read from remote repository|Repository not found/ diff --git a/features/package.feature b/features/package.feature index 74861450..996dab19 100644 --- a/features/package.feature +++ b/features/package.feature @@ -61,44 +61,46 @@ Feature: Manage WP-CLI packages When I run `wp --require=bad-command.php package list` Then STDERR should be empty - @require-php-7.2 @broken - Scenario: Revert the WP-CLI packages composer.json when fail to install/uninstall a package due to memory limit + Scenario: Revert composer.json when Composer cannot resolve an install or uninstall Given an empty directory - When I try `{INVOKE_WP_CLI_WITH_PHP_ARGS--dmemory_limit=10M -ddisable_functions=ini_set} package install runcommand/hook` + + When I run `wp package list --skip-update-check` + And I run `wp package path` + Then save STDOUT as {PACKAGE_PATH} + + When I run `cp {PACKAGE_PATH}/composer.json before.json` + And I try `wp package install runcommand/hook:999999.0.0` Then the return code should not be 0 And STDERR should contain: """ Reverted composer.json. """ - When I run `wp package install runcommand/hook` + When I run `cmp before.json {PACKAGE_PATH}/composer.json` + Then the return code should be 0 + + Given a kept/composer.json file: + """ + {"name":"local/kept","version":"1.0.0"} + """ + When I run `wp package install ./kept` + And I run `wp package install runcommand/hook` Then STDOUT should contain: """ Success: Package installed. """ - When I try `{INVOKE_WP_CLI_WITH_PHP_ARGS--dmemory_limit=10M -ddisable_functions=ini_set} package uninstall runcommand/hook` + When I run `wp eval "file_put_contents( '{PACKAGE_PATH}/composer.json', str_replace( '1.0.0', '999999.0.0', file_get_contents( '{PACKAGE_PATH}/composer.json' ) ) );" --skip-wordpress` + And I run `cp {PACKAGE_PATH}/composer.json before.json` + And I try `wp package uninstall runcommand/hook` Then the return code should not be 0 And STDERR should contain: """ Reverted composer.json. """ - # Create a default composer.json first to compare. - When I run `WP_CLI_PACKAGES_DIR={RUN_DIR}/mypackages wp package list` - Then the {RUN_DIR}/mypackages/composer.json file should exist - And save the {RUN_DIR}/mypackages/composer.json file as {MYPACKAGES_COMPOSER_JSON} - - When I try `WP_CLI_PACKAGES_DIR={RUN_DIR}/mypackages {INVOKE_WP_CLI_WITH_PHP_ARGS--dmemory_limit=10M -ddisable_functions=ini_set} package install runcommand/hook` - Then the return code should not be 0 - And STDERR should contain: - """ - Reverted composer.json. - """ - And the mypackages/composer.json file should be: - """ - {MYPACKAGES_COMPOSER_JSON} - """ + When I run `cmp before.json {PACKAGE_PATH}/composer.json` + Then the return code should be 0 @github-api Scenario: Try to run with a bad WP_CLI_PACKAGES_DIR/composer.json @@ -170,12 +172,9 @@ Feature: Manage WP-CLI packages Then the return code should be 1 And STDERR should contain: """ - Error: Package installation failed. - """ - And STDERR should contain: - """ - Repository not found + Error: Package installation failed (Composer return code 1). """ + And STDOUT should match /Repository not found|Could not read from remote repository/ And STDERR should contain: """ Reverted composer.json. @@ -193,12 +192,9 @@ Feature: Manage WP-CLI packages Then the return code should be 1 And STDERR should contain: """ - Error: Failed to update packages. - """ - And STDERR should contain: - """ - Repository not found + Error: Failed to update packages (Composer return code 1). """ + And STDOUT should match /Repository not found|Could not read from remote repository/ And STDERR should not contain: """ Reverted composer.json. @@ -316,3 +312,67 @@ Feature: Manage WP-CLI packages When I run `wp package uninstall runcommand/hook` Then STDERR should be empty + + Scenario: Download Composer once into the WP-CLI cache + When I run `wp package path` + Then save STDOUT as {PACKAGE_PATH} + + Given an empty directory + And an empty cache + And a local-package/composer.json file: + """ + {"name":"local/cache-test","version":"1.0.0"} + """ + + When I run `wp package install ./local-package` + Then STDERR should be empty + And STDOUT should match /Downloading Composer 2\.[0-9.]+ to .*composer\/composer-2\.[0-9.]+\.phar/ + And STDOUT should contain: + """ + {SUITE_CACHE_DIR}/composer/composer- + """ + + When I run `ls {SUITE_CACHE_DIR}/composer/composer-*.phar` + Then save STDOUT as {COMPOSER_PHAR} + And the {COMPOSER_PHAR} file should exist + + When I run `wp package update` + Then STDERR should be empty + And STDOUT should not contain: + """ + Downloading Composer + """ + + Scenario: Install using an explicitly configured Composer Phar + When I run `wp package path` + Then save STDOUT as {PACKAGE_PATH} + + Given an empty directory + And an empty cache + And a local-package/composer.json file: + """ + {"name":"local/binary-test","version":"1.0.0"} + """ + + When I run `wp package install ./local-package` + And I run `ls {SUITE_CACHE_DIR}/composer/composer-*.phar` + Then save STDOUT as {COMPOSER_PHAR} + + When I run `wp package uninstall local/binary-test` + And I run `WP_CLI_COMPOSER_BINARY={COMPOSER_PHAR} wp package install ./local-package` + Then STDERR should be empty + And STDOUT should contain: + """ + Success: Package installed. + """ + And STDOUT should not contain: + """ + Downloading Composer + """ + + When I run `WP_CLI_COMPOSER_BINARY={RUN_DIR}/missing wp package list --skip-update-check` + Then STDERR should be empty + And STDOUT should contain: + """ + local/binary-test + """ diff --git a/tests/phpunit/ComposerJsonTest.php b/tests/phpunit/ComposerJsonTest.php index 17e78a1f..7a386c1d 100644 --- a/tests/phpunit/ComposerJsonTest.php +++ b/tests/phpunit/ComposerJsonTest.php @@ -208,6 +208,31 @@ public function test_get_composer_json_path_backup_decoded() { putenv( false === $env_wp_cli_packages_dir ? 'WP_CLI_PACKAGES_DIR' : "WP_CLI_PACKAGES_DIR=$env_wp_cli_packages_dir" ); } + public function test_installed_packages_filters_dependencies_and_accepts_legacy_names() { + $env_test = getenv( 'WP_CLI_TEST_PACKAGE_GET_COMPOSER_JSON_PATH' ); + $env_dir = getenv( 'WP_CLI_PACKAGES_DIR' ); + putenv( 'WP_CLI_TEST_PACKAGE_GET_COMPOSER_JSON_PATH=1' ); + putenv( 'WP_CLI_PACKAGES_DIR=' . $this->temp_dir ); + mkdir( $this->temp_dir . 'vendor/composer', 0755, true ); + file_put_contents( $this->temp_dir . 'composer.json', '{"require":{"Vendor/Command":"*"}}' ); + file_put_contents( $this->temp_dir . 'vendor/composer/installed.json', '{"packages":[{"name":"vendor/command","version":"1.0.0"},{"name":"vendor/dependency","version":"2.0.0"}]}' ); + $method = new ReflectionMethod( 'Package_Command', 'get_installed_packages' ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + try { + $packages = $method->invoke( new Package_Command() ); + $this->assertSame( [ 'vendor/command' ], array_column( $packages, 'name' ) ); + } finally { + unlink( $this->temp_dir . 'composer.json' ); + unlink( $this->temp_dir . 'vendor/composer/installed.json' ); + rmdir( $this->temp_dir . 'vendor/composer' ); + rmdir( $this->temp_dir . 'vendor' ); + putenv( false === $env_test ? 'WP_CLI_TEST_PACKAGE_GET_COMPOSER_JSON_PATH' : 'WP_CLI_TEST_PACKAGE_GET_COMPOSER_JSON_PATH=' . $env_test ); + putenv( false === $env_dir ? 'WP_CLI_PACKAGES_DIR' : 'WP_CLI_PACKAGES_DIR=' . $env_dir ); + } + } + private function mac_safe_path( $path ) { $path = \WP_CLI\Path::normalize( $path ); $path = preg_replace( '#^/private/(var|tmp)/#i', '/$1/', $path ); diff --git a/tests/phpunit/ComposerPharTest.php b/tests/phpunit/ComposerPharTest.php new file mode 100644 index 00000000..2335f36b --- /dev/null +++ b/tests/phpunit/ComposerPharTest.php @@ -0,0 +1,112 @@ +environment = []; + foreach ( [ 'WP_CLI_COMPOSER_BINARY', 'WP_CLI_PHP_ARGS', 'COMPOSER_AUTH' ] as $name ) { + $this->environment[ $name ] = getenv( $name ); + } + $this->directory = sys_get_temp_dir() . '/' . uniqid( 'composer-test-', true ); + mkdir( $this->directory ); + file_put_contents( $this->directory . '/composer.phar', 'directory . '/composer', '' ); + } + + public function tear_down() { + foreach ( $this->environment as $name => $value ) { + putenv( false === $value ? $name : $name . '=' . $value ); + } + unlink( $this->directory . '/composer.phar' ); + unlink( $this->directory . '/composer' ); + rmdir( $this->directory ); + parent::tear_down(); + } + + public function test_phar_command_and_php_arguments() { + $binary = $this->directory . '/composer.phar'; + putenv( 'WP_CLI_COMPOSER_BINARY=' . $binary ); + putenv( 'WP_CLI_PHP_ARGS=-d memory_limit=123M' ); + $composer = new ComposerPhar(); + $command = $composer->command( [ 'update', 'vendor/package:^1.0' ], $this->directory . '/with spaces', true ); + $this->assertSame( + Utils\esc_cmd( '%s', WP_CLI::get_php_binary() ) . ' -d memory_limit=123M ' . Utils\esc_cmd( '%s', $binary ) . ' ' . implode( ' ', array_map( 'escapeshellarg', [ 'update', 'vendor/package:^1.0', '--working-dir=' . $this->directory . '/with spaces', '--no-interaction', '--no-ansi', '--no-progress', '--quiet' ] ) ), + $command + ); + $argv = $composer->run_json( [ 'outdated', '--format=json' ], $this->directory ); + $this->assertSame( [ $binary, 'outdated', '--format=json', '--working-dir=' . $this->directory, '--no-interaction', '--no-ansi' ], $argv ); + } + + public function test_executable_does_not_use_php_arguments() { + $binary = $this->directory . '/composer'; + putenv( 'WP_CLI_COMPOSER_BINARY=' . $binary ); + putenv( 'WP_CLI_PHP_ARGS=-d memory_limit=123M' ); + $command = ( new ComposerPhar() )->command( [ 'update' ], $this->directory ); + $this->assertSame( 0, strpos( $command, Utils\esc_cmd( '%s', $binary ) . ' ' ) ); + $this->assertFalse( strpos( $command, 'memory_limit' ) ); + } + + public function test_unreadable_override() { + putenv( 'WP_CLI_COMPOSER_BINARY=' . $this->directory . '/missing' ); + $this->expectException( RuntimeException::class ); + $this->expectExceptionMessage( 'WP_CLI_COMPOSER_BINARY is not readable:' ); + ( new ComposerPhar() )->locate(); + } + + public function test_child_environment_and_quiet_exit_status() { + putenv( 'WP_CLI_COMPOSER_BINARY=' . $this->directory . '/composer.phar' ); + putenv( 'WP_CLI_PHP_ARGS' ); + putenv( 'COMPOSER_AUTH={"test":"inherited"}' ); + file_put_contents( $this->directory . '/composer.phar', 'assertSame( [ '{"test":"inherited"}', '1' ], ( new ComposerPhar() )->run_json( [ 'outdated' ], $this->directory ) ); + file_put_contents( $this->directory . '/composer.phar', 'assertSame( 7, ( new ComposerPhar() )->run( [ 'update' ], $this->directory, true ) ); + } + + public function test_invalid_json() { + putenv( 'WP_CLI_COMPOSER_BINARY=' . $this->directory . '/composer.phar' ); + file_put_contents( $this->directory . '/composer.phar', 'expectException( RuntimeException::class ); + ( new ComposerPhar() )->run_json( [ 'outdated' ], $this->directory ); + } + + public function test_both_streams_are_logged_without_ansi_or_empty_lines() { + putenv( 'WP_CLI_COMPOSER_BINARY=' . $this->directory . '/composer.phar' ); + putenv( 'WP_CLI_PHP_ARGS' ); + file_put_contents( $this->directory . '/composer.phar', 'setAccessible( true ); + } + $previous = $property->getValue(); + $logger = new WP_CLI\Loggers\Execution(); + WP_CLI::set_logger( $logger ); + try { + $this->assertSame( 0, ( new ComposerPhar() )->run( [ 'update' ], $this->directory ) ); + $lines = explode( "\n", $logger->stdout ); + sort( $lines ); + $this->assertSame( [ '', 'last line', 'stderr', 'stdout' ], $lines ); + $this->assertSame( '', $logger->stderr ); + } finally { + WP_CLI::set_logger( $previous ); + } + } + + public function test_nonzero_json_exit() { + putenv( 'WP_CLI_COMPOSER_BINARY=' . $this->directory . '/composer.phar' ); + file_put_contents( $this->directory . '/composer.phar', 'expectException( RuntimeException::class ); + ( new ComposerPhar() )->run_json( [ 'outdated' ], $this->directory ); + } +} diff --git a/tests/phpunit/PackageMetadataTest.php b/tests/phpunit/PackageMetadataTest.php new file mode 100644 index 00000000..1ebf4130 --- /dev/null +++ b/tests/phpunit/PackageMetadataTest.php @@ -0,0 +1,86 @@ +assertSame( [ 'Vendor/Command', 'vendor/other' ], array_keys( $packages ) ); + $this->assertSame( [ 'v1.0.0', 'dev-main' ], $packages['Vendor/Command']['versions'] ); + $this->assertSame( 'First Author, Second Author', $packages['Vendor/Command']['authors'] ); + $this->assertSame( 'A command', $packages['Vendor/Command']['description'] ); + $this->assertSame( '', $packages['vendor/other']['authors'] ); + $this->assertSame( [], PackageIndex::parse( [ 'includes' => [ 'include.json' => [] ] ] ) ); + } + + public function test_installed_json_shapes() { + $path = tempnam( sys_get_temp_dir(), 'installed-' ); + $package = [ + 'name' => 'vendor/command', + 'version' => 'dev-main', + 'source' => [ 'reference' => 'abcdef123456789' ], + 'authors' => [ [ 'name' => 'Author' ] ], + ]; + try { + file_put_contents( $path, json_encode( [ $package ] ) ); + $composer_one = InstalledPackages::read( $path ); + file_put_contents( + $path, + json_encode( + [ + 'packages' => [ $package ], + 'dev' => true, + ] + ) + ); + $this->assertSame( $composer_one, InstalledPackages::read( $path ) ); + $this->assertSame( 'dev-main abcdef123456789', $composer_one['vendor/command']['full_version'] ); + $this->assertSame( 'Author', $composer_one['vendor/command']['authors'] ); + $package['version'] = 'v1.0.0'; + file_put_contents( $path, json_encode( [ $package ] ) ); + $this->assertSame( 'v1.0.0', InstalledPackages::read( $path )['vendor/command']['full_version'] ); + } finally { + unlink( $path ); + } + $this->assertSame( [], InstalledPackages::read( $path ) ); + } + + public function test_package_names() { + foreach ( [ 'vendor/package', 'Vendor/Package', 'vendor/foo--bar', 'vendor/foo_bar.baz' ] as $name ) { + $this->assertNull( InstalledPackages::has_naming_error( $name ), $name ); + } + foreach ( [ '..', '../outside', 'vendor/../outside', '/absolute', 'vendor/foo---bar', "vendor/package\n" ] as $name ) { + $this->assertNotNull( InstalledPackages::has_naming_error( $name ), $name ); + } + } + + public function test_outdated_mapping() { + $package = [ + 'name' => 'Vendor/Command', + 'version' => 'v1.0.0', + ]; + $updates = [ + 'installed' => [ + [ + 'name' => 'vendor/other', + 'latest' => 'v5.0.0', + ], + [ + 'name' => 'vendor/command', + 'latest' => 'v2.0.0', + ], + ], + ]; + $updated = InstalledPackages::with_update( $package, $updates ); + $this->assertSame( 'available', $updated['update'] ); + $this->assertSame( 'v2.0.0', $updated['update_version'] ); + $package['version'] = 'v2.0.0'; + $this->assertSame( 'none', InstalledPackages::with_update( $package, $updates )['update'] ); + $this->assertSame( '', InstalledPackages::with_update( $package, [] )['update_version'] ); + $this->assertSame( 'error', InstalledPackages::with_update( $package, null )['update'] ); + $this->assertSame( 'error', InstalledPackages::with_update( $package, null )['update_version'] ); + } +} diff --git a/tests/phpunit/fixtures/package-index.json b/tests/phpunit/fixtures/package-index.json new file mode 100644 index 00000000..3c3c3318 --- /dev/null +++ b/tests/phpunit/fixtures/package-index.json @@ -0,0 +1,15 @@ +{ + "packages": { + "Vendor/Command": { + "v1.0.0": { + "name": "Vendor/Command", + "description": "A command", + "authors": [{"name": "First Author"}, {"name": "Second Author"}] + }, + "dev-main": {"name": "Vendor/Command"} + }, + "vendor/other": { + "dev-master": {"name": "vendor/other"} + } + } +} From 788e992926e0af2f15cfd40bcf16f9896dbf37b7 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Mon, 14 Sep 2026 17:39:04 +0200 Subject: [PATCH 03/10] Report a Composer exit code only when Composer ran A download, checksum or start-up failure before the child process exists left $res at 1, so the error said "Composer return code 1" for a run Composer never made. Start from false, as before the child process, and append the code only when there is one. --- src/Package_Command.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Package_Command.php b/src/Package_Command.php index 02748de3..4dee888e 100644 --- a/src/Package_Command.php +++ b/src/Package_Command.php @@ -409,7 +409,7 @@ public function install( $args, $assoc_args ) { // Try running the installer, but revert composer.json if failed WP_CLI::log( 'Using Composer to install the package...' ); WP_CLI::log( '---' ); - $res = 1; + $res = false; try { $res = ( new ComposerPhar( $insecure ) )->run( [ 'update', '--prefer-source' ], dirname( $json_path ) ); } catch ( Exception $e ) { @@ -424,7 +424,7 @@ public function install( $args, $assoc_args ) { $revert = false; WP_CLI::success( 'Package installed.' ); } else { - $res_msg = $res ? " (Composer return code {$res})" : ''; // $res may be null apparently. + $res_msg = false === $res ? '' : " (Composer return code {$res})"; // False: Composer never started. WP_CLI::debug( "composer.json content:\n" . file_get_contents( $json_path ), 'packages' ); WP_CLI::error( "Package installation failed{$res_msg}." ); } @@ -684,7 +684,7 @@ public function update( $args, $assoc_args = [] ) { WP_CLI::log( 'Using Composer to update packages...' ); WP_CLI::log( '---' ); - $res = 1; + $res = false; try { $res = ( new ComposerPhar() )->run( array_merge( [ 'update' ], $packages_to_update, [ '--prefer-source' ] ), $packages_dir ); foreach ( InstalledPackages::read( $installed_path ) as $name => $package ) { @@ -723,7 +723,7 @@ public function update( $args, $assoc_args = [] ) { WP_CLI::success( 'Packages updated.' ); } } else { - $res_msg = $res ? " (Composer return code {$res})" : ''; // $res may be null apparently. + $res_msg = false === $res ? '' : " (Composer return code {$res})"; // False: Composer never started. WP_CLI::error( "Failed to update packages{$res_msg}." ); } } @@ -795,7 +795,7 @@ public function uninstall( $args, $assoc_args ) { file_put_contents( $json_path, $manipulator->getContents() ); WP_CLI::log( 'Removing package directories and regenerating autoloader...' ); - $res = 1; + $res = false; try { $res = ( new ComposerPhar( $insecure ) )->run( [ 'update', '--prefer-source' ], dirname( $json_path ), true ); } catch ( Exception $e ) { @@ -806,7 +806,7 @@ public function uninstall( $args, $assoc_args ) { $revert = false; WP_CLI::success( 'Uninstalled package.' ); } else { - $res_msg = $res ? " (Composer return code {$res})" : ''; // $res may be null apparently. + $res_msg = false === $res ? '' : " (Composer return code {$res})"; // False: Composer never started. WP_CLI::error( "Package removal failed{$res_msg}." ); } } From 430bd3217189eb032fd65d537981ff66d994c01b Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Mon, 14 Sep 2026 17:39:17 +0200 Subject: [PATCH 04/10] Show Composer's output during uninstall The quiet run hid the first-use Composer download notice and, on a failed removal, the resolver error that explains it. Relay the output the way install and update do. --- src/Package_Command.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Package_Command.php b/src/Package_Command.php index 4dee888e..f9e52976 100644 --- a/src/Package_Command.php +++ b/src/Package_Command.php @@ -795,12 +795,14 @@ public function uninstall( $args, $assoc_args ) { file_put_contents( $json_path, $manipulator->getContents() ); WP_CLI::log( 'Removing package directories and regenerating autoloader...' ); + WP_CLI::log( '---' ); $res = false; try { - $res = ( new ComposerPhar( $insecure ) )->run( [ 'update', '--prefer-source' ], dirname( $json_path ), true ); + $res = ( new ComposerPhar( $insecure ) )->run( [ 'update', '--prefer-source' ], dirname( $json_path ) ); } catch ( Exception $e ) { WP_CLI::warning( $e->getMessage() ); } + WP_CLI::log( '---' ); if ( 0 === $res ) { $revert = false; From 5ef968c8e4be11628324976e45e665853a06c8af Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Mon, 14 Sep 2026 17:39:35 +0200 Subject: [PATCH 05/10] Keep the update outcome apart from the change report Reading installed.json after a successful update was inside the same try as the Composer run. When that read failed, the warning was followed by "Package already at latest version" for named packages, since the change list was empty. Read the report on its own and fall back to the plain success message when it cannot be read. --- src/Package_Command.php | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/Package_Command.php b/src/Package_Command.php index f9e52976..ec9335c1 100644 --- a/src/Package_Command.php +++ b/src/Package_Command.php @@ -687,21 +687,31 @@ public function update( $args, $assoc_args = [] ) { $res = false; try { $res = ( new ComposerPhar() )->run( array_merge( [ 'update' ], $packages_to_update, [ '--prefer-source' ] ), $packages_dir ); - foreach ( InstalledPackages::read( $installed_path ) as $name => $package ) { - if ( isset( $before[ $name ] ) && ( $before[ $name ]['version'] !== $package['version'] || $before[ $name ]['source_reference'] !== $package['source_reference'] ) ) { - $updated_packages[] = $name; - } - } } catch ( Exception $e ) { WP_CLI::warning( $e->getMessage() ); } WP_CLI::log( '---' ); + // Composer succeeded; what it changed is a report, and an unreadable report does not undo the update. + $report = true; + if ( 0 === $res ) { + try { + foreach ( InstalledPackages::read( $installed_path ) as $name => $package ) { + if ( isset( $before[ $name ] ) && ( $before[ $name ]['version'] !== $package['version'] || $before[ $name ]['source_reference'] !== $package['source_reference'] ) ) { + $updated_packages[] = $name; + } + } + } catch ( Exception $e ) { + WP_CLI::warning( $e->getMessage() ); + $report = false; + } + } + // TODO: The --insecure (to be added here) flag should cause another Composer run with verify disabled. if ( 0 === $res ) { $num_packages = count( $packages_to_update ); - if ( $num_packages > 0 ) { + if ( $num_packages > 0 && $report ) { // When specific packages were requested, report on actual updates $num_updated = count( array_intersect( $packages_to_update, $updated_packages ) ); if ( 0 === $num_updated ) { From e7dadbaaee8235689dffbae95b9a8b7eed14df86 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Mon, 14 Sep 2026 17:40:01 +0200 Subject: [PATCH 06/10] Report the update version without Composer's source reference composer outdated writes dev versions as "dev-main 180a970", so a package installed from a branch showed the commit in update_version where the old code showed the version alone. Cut at the first space, and treat what Composer marks up-to-date as no update. --- src/WP_CLI/Package/InstalledPackages.php | 12 +++++++++--- tests/phpunit/PackageMetadataTest.php | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/WP_CLI/Package/InstalledPackages.php b/src/WP_CLI/Package/InstalledPackages.php index 5657293e..e17cea58 100644 --- a/src/WP_CLI/Package/InstalledPackages.php +++ b/src/WP_CLI/Package/InstalledPackages.php @@ -49,11 +49,17 @@ public static function with_update( array $package, $outdated ) { $package['update'] = null === $outdated ? 'error' : 'none'; $package['update_version'] = null === $outdated ? 'error' : ''; foreach ( $outdated['installed'] ?? [] as $candidate ) { - if ( strtolower( $package['name'] ) === strtolower( $candidate['name'] ) && $candidate['latest'] !== $package['version'] ) { - $package['update'] = 'available'; - $package['update_version'] = $candidate['latest']; + if ( strtolower( $package['name'] ) !== strtolower( $candidate['name'] ) ) { + continue; + } + // Composer appends the source reference to dev versions ("dev-main 180a970"); the column shows the version. + $latest = explode( ' ', trim( (string) $candidate['latest'] ), 2 )[0]; + if ( 'up-to-date' === ( $candidate['latest-status'] ?? '' ) || $candidate['latest'] === $package['version'] ) { break; } + $package['update'] = 'available'; + $package['update_version'] = $latest; + break; } return $package; } diff --git a/tests/phpunit/PackageMetadataTest.php b/tests/phpunit/PackageMetadataTest.php index 1ebf4130..a336f4f0 100644 --- a/tests/phpunit/PackageMetadataTest.php +++ b/tests/phpunit/PackageMetadataTest.php @@ -82,5 +82,24 @@ public function test_outdated_mapping() { $this->assertSame( '', InstalledPackages::with_update( $package, [] )['update_version'] ); $this->assertSame( 'error', InstalledPackages::with_update( $package, null )['update'] ); $this->assertSame( 'error', InstalledPackages::with_update( $package, null )['update_version'] ); + + $dev = [ + 'name' => 'vendor/branch', + 'version' => 'dev-main', + ]; + $updates = [ + 'installed' => [ + [ + 'name' => 'vendor/branch', + 'latest' => 'dev-main 180a970', + 'latest-status' => 'semver-safe-update', + ], + ], + ]; + $updated = InstalledPackages::with_update( $dev, $updates ); + $this->assertSame( 'available', $updated['update'] ); + $this->assertSame( 'dev-main', $updated['update_version'] ); + $updates['installed'][0]['latest-status'] = 'up-to-date'; + $this->assertSame( 'none', InstalledPackages::with_update( $dev, $updates )['update'] ); } } From 4df2f1ff33a0f8856a9da14793dcdb78d2211a0f Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Mon, 14 Sep 2026 17:40:02 +0200 Subject: [PATCH 07/10] Verify package index includes against their SHA-1 packages.json declares a sha1 for each include, and Composer checks it when it reads such a repository. Hash the include bytes before parsing and refuse a mismatch. --- src/WP_CLI/Package/PackageIndex.php | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/WP_CLI/Package/PackageIndex.php b/src/WP_CLI/Package/PackageIndex.php index 49e0d2d9..d50f44ea 100644 --- a/src/WP_CLI/Package/PackageIndex.php +++ b/src/WP_CLI/Package/PackageIndex.php @@ -22,13 +22,21 @@ public function __construct( $insecure = false ) { public function packages() { $base = 'https://wp-cli.org/package-index/'; $index = $this->fetch( $base . 'packages.json' ); - $packages = self::parse( $index ); - foreach ( $index['includes'] ?? [] as $file => $metadata ) { - $packages = array_replace( $packages, self::parse( $this->fetch( $base . $file ) ) ); + $packages = self::parse( $index['data'] ); + foreach ( $index['data']['includes'] ?? [] as $file => $metadata ) { + $include = $this->fetch( $base . $file ); + // The index names each include by its SHA-1, as Composer repositories do; a mismatch is a bad download. + if ( isset( $metadata['sha1'] ) && ! hash_equals( strtolower( (string) $metadata['sha1'] ), sha1( $include['body'] ) ) ) { + throw new RuntimeException( "Package index include failed SHA-1 verification: {$file}" ); + } + $packages = array_replace( $packages, self::parse( $include['data'] ) ); } return $packages; } + /** + * @return array{data: array, body: string} Decoded JSON and the bytes it came from. + */ private function fetch( $url ) { $response = Utils\http_request( 'GET', @@ -44,7 +52,10 @@ private function fetch( $url ) { if ( 200 !== $response->status_code || ! is_array( $data ) ) { throw new RuntimeException( "Failed to read package index: {$url}" ); } - return $data; + return [ + 'data' => $data, + 'body' => (string) $response->body, + ]; } /** From 91a31846463cbce938920f2618c5fa19c3549042 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Mon, 14 Sep 2026 17:40:31 +0200 Subject: [PATCH 08/10] Cache the Composer version list for a day Every package command asked getcomposer.org which Composer to run. Keep the list in the WP-CLI cache (composer/versions.json) and reuse it for a day; without network, a stale copy still names the version, and the cached Phar fallback stays behind it. --- src/WP_CLI/Package/ComposerPhar.php | 53 ++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/src/WP_CLI/Package/ComposerPhar.php b/src/WP_CLI/Package/ComposerPhar.php index 430d4bf9..0ccf26c3 100644 --- a/src/WP_CLI/Package/ComposerPhar.php +++ b/src/WP_CLI/Package/ComposerPhar.php @@ -11,6 +11,9 @@ */ class ComposerPhar { + const VERSIONS_URL = 'https://getcomposer.org/versions'; + const VERSIONS_TTL = 86400; + private $insecure; private $path; @@ -35,16 +38,11 @@ public function locate( $quiet = false ) { $cache = WP_CLI::get_cache(); $version = null; - try { - $versions = json_decode( $this->request( 'https://getcomposer.org/versions' )->body, true ); - foreach ( $versions['stable'] ?? [] as $release ) { - if ( preg_match( '/^2\.\d+\.\d+$/D', $release['version'] ) && $release['min-php'] <= PHP_VERSION_ID ) { - $version = $release['version']; - break; - } + foreach ( $this->versions( $cache )['stable'] ?? [] as $release ) { + if ( preg_match( '/^2\.\d+\.\d+$/D', $release['version'] ) && $release['min-php'] <= PHP_VERSION_ID ) { + $version = $release['version']; + break; } - } catch ( \Exception $e ) { - WP_CLI::debug( $e->getMessage(), 'packages' ); } if ( null === $version ) { // Offline or getcomposer.org unreachable: reuse the newest Composer already in the cache. @@ -98,6 +96,43 @@ static function () use ( $temp, $temp_dir ) { return $path; } + /** + * The release list from getcomposer.org. A copy lives in the cache for a day, so a run of package + * commands costs one request; when the network is down, a stale copy still names the version to use. + * + * @return array Decoded list, empty when neither the network nor the cache has one. + */ + private function versions( $cache ) { + $key = 'composer/versions.json'; + if ( $cache->is_enabled() ) { + $fresh = $cache->read( $key, self::VERSIONS_TTL ); + if ( false !== $fresh && is_array( json_decode( $fresh, true ) ) ) { + return json_decode( $fresh, true ); + } + } + try { + $body = $this->request( self::VERSIONS_URL )->body; + $versions = json_decode( $body, true ); + if ( ! is_array( $versions ) ) { + throw new RuntimeException( 'Composer version list is not valid JSON.' ); + } + if ( $cache->is_enabled() ) { + $cache->write( $key, $body ); + } + return $versions; + } catch ( \Exception $e ) { + WP_CLI::debug( $e->getMessage(), 'packages' ); + } + if ( $cache->is_enabled() ) { + $stale = $cache->read( $key ); + if ( false !== $stale && is_array( json_decode( $stale, true ) ) ) { + WP_CLI::debug( 'Using the cached Composer version list; getcomposer.org is unreachable.', 'packages' ); + return json_decode( $stale, true ); + } + } + return []; + } + /** * Composer versions present in the cache, newest first. * From 58c677eeb336b67b93ad8063c47c1e1c08ab5dda Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Mon, 14 Sep 2026 17:40:31 +0200 Subject: [PATCH 09/10] Skip expired Phars in the offline fallback The fallback listed the cache directory itself and then asked the cache for the newest entry; an expired one made has() return false, which became the Composer binary. Ask the cache about each candidate, newest first, and download when none is usable. --- src/WP_CLI/Package/ComposerPhar.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/WP_CLI/Package/ComposerPhar.php b/src/WP_CLI/Package/ComposerPhar.php index 0ccf26c3..e67383d0 100644 --- a/src/WP_CLI/Package/ComposerPhar.php +++ b/src/WP_CLI/Package/ComposerPhar.php @@ -46,11 +46,13 @@ public function locate( $quiet = false ) { } if ( null === $version ) { // Offline or getcomposer.org unreachable: reuse the newest Composer already in the cache. - $cached = self::cached_versions( $cache ); - if ( $cached ) { - $this->path = $cache->has( "composer/composer-{$cached[0]}.phar" ); - WP_CLI::debug( "Using cached Composer {$cached[0]}; version list unavailable.", 'packages' ); - return $this->path; + foreach ( self::cached_versions( $cache ) as $cached ) { + $path = $cache->has( "composer/composer-{$cached}.phar" ); // False once the cache's expiry has passed. + if ( $path ) { + $this->path = $path; + WP_CLI::debug( "Using cached Composer {$cached}; version list unavailable.", 'packages' ); + return $path; + } } $version = 'latest-stable'; } From b6e49abf78c35dc2534eeb30285b532e92419677 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Mon, 14 Sep 2026 17:40:50 +0200 Subject: [PATCH 10/10] Use wp eval instead of cp, cmp and ls in the Behat scenarios The scenarios compare composer.json before and after a reverted run and look up the cached Composer Phar. Do both through WP-CLI, so the steps run wherever the suite runs, and check that the version list is cached alongside the Phar. --- features/package.feature | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/features/package.feature b/features/package.feature index 996dab19..b0fa0bd0 100644 --- a/features/package.feature +++ b/features/package.feature @@ -68,16 +68,21 @@ Feature: Manage WP-CLI packages And I run `wp package path` Then save STDOUT as {PACKAGE_PATH} - When I run `cp {PACKAGE_PATH}/composer.json before.json` - And I try `wp package install runcommand/hook:999999.0.0` + When I run `wp eval "echo md5_file( '{PACKAGE_PATH}/composer.json' );" --skip-wordpress` + Then save STDOUT as {COMPOSER_JSON_MD5} + + When I try `wp package install runcommand/hook:999999.0.0` Then the return code should not be 0 And STDERR should contain: """ Reverted composer.json. """ - When I run `cmp before.json {PACKAGE_PATH}/composer.json` - Then the return code should be 0 + When I run `wp eval "echo md5_file( '{PACKAGE_PATH}/composer.json' );" --skip-wordpress` + Then STDOUT should be: + """ + {COMPOSER_JSON_MD5} + """ Given a kept/composer.json file: """ @@ -91,16 +96,21 @@ Feature: Manage WP-CLI packages """ When I run `wp eval "file_put_contents( '{PACKAGE_PATH}/composer.json', str_replace( '1.0.0', '999999.0.0', file_get_contents( '{PACKAGE_PATH}/composer.json' ) ) );" --skip-wordpress` - And I run `cp {PACKAGE_PATH}/composer.json before.json` - And I try `wp package uninstall runcommand/hook` + And I run `wp eval "echo md5_file( '{PACKAGE_PATH}/composer.json' );" --skip-wordpress` + Then save STDOUT as {COMPOSER_JSON_MD5} + + When I try `wp package uninstall runcommand/hook` Then the return code should not be 0 And STDERR should contain: """ Reverted composer.json. """ - When I run `cmp before.json {PACKAGE_PATH}/composer.json` - Then the return code should be 0 + When I run `wp eval "echo md5_file( '{PACKAGE_PATH}/composer.json' );" --skip-wordpress` + Then STDOUT should be: + """ + {COMPOSER_JSON_MD5} + """ @github-api Scenario: Try to run with a bad WP_CLI_PACKAGES_DIR/composer.json @@ -332,9 +342,10 @@ Feature: Manage WP-CLI packages {SUITE_CACHE_DIR}/composer/composer- """ - When I run `ls {SUITE_CACHE_DIR}/composer/composer-*.phar` + When I run `wp eval "echo current( glob( '{SUITE_CACHE_DIR}/composer/composer-*.phar' ) );" --skip-wordpress` Then save STDOUT as {COMPOSER_PHAR} And the {COMPOSER_PHAR} file should exist + And the {SUITE_CACHE_DIR}/composer/versions.json file should exist When I run `wp package update` Then STDERR should be empty @@ -355,7 +366,7 @@ Feature: Manage WP-CLI packages """ When I run `wp package install ./local-package` - And I run `ls {SUITE_CACHE_DIR}/composer/composer-*.phar` + And I run `wp eval "echo current( glob( '{SUITE_CACHE_DIR}/composer/composer-*.phar' ) );" --skip-wordpress` Then save STDOUT as {COMPOSER_PHAR} When I run `wp package uninstall local/binary-test`