From 2ef13461ce7a91eae35140f1e25987d6e74ce407 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Fri, 11 Sep 2026 18:12:25 +0200 Subject: [PATCH 1/2] 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 2/2] 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"} + } + } +}