diff --git a/components/Blueprints/DataReference/DataReferenceResolver.php b/components/Blueprints/DataReference/DataReferenceResolver.php index 2976b63c..7b45b79a 100644 --- a/components/Blueprints/DataReference/DataReferenceResolver.php +++ b/components/Blueprints/DataReference/DataReferenceResolver.php @@ -52,7 +52,7 @@ public function __construct( Client $client, ?string $tmpRoot = null ) { $this->tmpRoot = $tmpRoot ?: wp_unix_sys_get_temp_dir(); } - public function setExecutionContext( Filesystem $executionContext ) { + public function setExecutionContext( ?Filesystem $executionContext ) { $this->executionContext = $executionContext; } diff --git a/components/Blueprints/Runner.php b/components/Blueprints/Runner.php index 08e3d55f..fead0a1a 100644 --- a/components/Blueprints/Runner.php +++ b/components/Blueprints/Runner.php @@ -59,6 +59,13 @@ use function WordPress\Zip\is_zip_file_stream; class Runner { + const EXECUTION_MODE_CREATE_NEW_SITE = 'create-new-site'; + const EXECUTION_MODE_APPLY_TO_EXISTING_SITE = 'apply-to-existing-site'; + const EXECUTION_MODES = [ + self::EXECUTION_MODE_CREATE_NEW_SITE, + self::EXECUTION_MODE_APPLY_TO_EXISTING_SITE, + ]; + /** * @var RunnerConfiguration */ @@ -113,7 +120,7 @@ public function __construct( RunnerConfiguration $configuration ) { $this->configuration = $configuration; $this->validateConfiguration( $configuration ); - $this->client = new Client(); + $this->client = apply_filters('blueprint.http_client', new Client()); $this->mainTracker = new Tracker(); // Set up progress logging @@ -134,17 +141,17 @@ private function validateConfiguration( RunnerConfiguration $config ): void { // Validate execution mode $mode = $config->getExecutionMode(); - if ( ! in_array( $mode, [ 'create-new-site', 'apply-to-existing-site' ], true ) ) { - throw new BlueprintExecutionException( "Execution mode must be either 'create-new-site' or 'apply-to-existing-site'." ); + if ( ! in_array( $mode, self::EXECUTION_MODES, true ) ) { + throw new BlueprintExecutionException( "Execution mode must be one of: " . implode( ', ', self::EXECUTION_MODES ) ); } // Validate site URL // Note: $options is not defined in this context, so we skip this block. // If you want to validate the site URL, you should use $config->getTargetSiteUrl(). $siteUrl = $config->getTargetSiteUrl(); - if ( $mode === 'create-new-site' ) { + if ( $mode === self::EXECUTION_MODE_CREATE_NEW_SITE ) { if ( empty( $siteUrl ) ) { - throw new BlueprintExecutionException( "Site URL is required when the execution mode is 'create-new-site'." ); + throw new BlueprintExecutionException( sprintf( "Site URL is required when the execution mode is '%s'.", self::EXECUTION_MODE_CREATE_NEW_SITE ) ); } } if ( ! empty( $siteUrl ) && ! filter_var( $siteUrl, FILTER_VALIDATE_URL ) ) { @@ -194,6 +201,7 @@ private function validateConfiguration( RunnerConfiguration $config ): void { public function run(): void { $tempRoot = wp_unix_sys_get_temp_dir() . '/wp-blueprints-runtime-' . uniqid(); + // TODO: Are there cases where we should not have these permissions? mkdir( $tempRoot, 0777, true ); @@ -226,8 +234,13 @@ public function run(): void { $targetSiteFs = LocalFilesystem::create( $this->configuration->getTargetSiteRoot() ); $wpCliReference = DataReference::create( 'https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar' ); - $execution_context_root = $this->blueprintExecutionContext->get_meta()['root']; - assert(is_string($execution_context_root) && strlen($execution_context_root) > 0, 'Assertion failed: Execution context root was empty.'); + $execution_context = $this->blueprintExecutionContext->get_meta(); + if( + isset($execution_context['root']) && + ( !is_string($execution_context['root']) || strlen($execution_context['root']) === 0) + ) { + throw new BlueprintExecutionException('Execution context was a local directory, but the Runner could not determine the root directory. This should never happen. Please report this as a bug.'); + } $this->runtime = new Runtime( $targetSiteFs, @@ -237,7 +250,7 @@ public function run(): void { $this->blueprintArray, $tempRoot, $wpCliReference, - $execution_context_root + isset($execution_context['root']) ? $execution_context['root'] : null ); $this->progressObserver->setRuntime( $this->runtime ); $progress['wpCli']->setCaption( 'Downloading WP-CLI' ); @@ -246,16 +259,21 @@ public function run(): void { ], $progress['wpCli'] ); $progress['targetResolution']->setCaption( 'Resolving target site' ); - if ( $this->configuration->getExecutionMode() === 'apply-to-existing-site' ) { + if ( $this->configuration->getExecutionMode() === self::EXECUTION_MODE_APPLY_TO_EXISTING_SITE ) { ExistingSiteResolver::resolve( $this->runtime, $progress['targetResolution'], $this->wpVersionConstraint ); } else { NewSiteResolver::resolve( $this->runtime, $progress['targetResolution'], $this->wpVersionConstraint, $this->recommendedWpVersion ); } $progress['targetResolution']->finish(); + do_action('blueprint.target_resolved'); + $progress['data']->setCaption( 'Resolving data references' ); $this->assets->startEagerResolution( $this->dataReferencesToAutoResolve, $progress['data'] ); $this->executePlan( $progress['execution'], $plan, $this->runtime ); + + // @TODO: Assert WordPress is still correctly installed + $progress->finish(); } finally { // TODO: Optionally preserve workspace in case of error? Support resuming after error? @@ -288,7 +306,14 @@ private function loadBlueprint() { $blueprintString = $resolved->getStream()->consume_all(); $this->blueprintExecutionContext = LocalFilesystem::create( dirname( $reference->get_path() ) ); } else { + // For the purposes of Blueprint resolution, the execution context is the + // current working directory. This way, a path such as ./blueprint.json + // will mean "a blueprint.json file in the current working directory" and not + // "a ./blueprint.json path without a point of reference". + $this->assets->setExecutionContext( LocalFilesystem::create( getcwd() ) ); $resolved = $this->assets->resolve( $reference ); + $this->assets->setExecutionContext( null ); + if ( $resolved instanceof File ) { $stream = $resolved->getStream(); @@ -376,6 +401,8 @@ private function validateBlueprint(): void { $this->configuration->getLogger()->debug( 'Final resolved Blueprint: ' . json_encode( $this->blueprintArray, JSON_PRETTY_PRINT ) ); + $this->blueprintArray = apply_filters( 'blueprint.resolved', $this->blueprintArray ); + // Assert the Blueprint conforms to the latest JSON schema. $v = new HumanFriendlySchemaValidator( json_decode( file_get_contents( __DIR__ . '/Versions/Version2/json-schema/schema-v2.json' ), true ) @@ -438,7 +465,7 @@ private function validateBlueprint(): void { // WordPress Version Constraint if ( isset( $this->blueprintArray['wordpressVersion'] ) ) { $wp_version = $this->blueprintArray['wordpressVersion']; - $recommended = null; + $min = $max = $recommended = null; if ( is_string( $wp_version ) ) { $this->recommendedWpVersion = $wp_version; $recommended = WordPressVersion::fromString( $wp_version ); @@ -465,6 +492,8 @@ private function validateBlueprint(): void { $this->recommendedWpVersion = $wp_version['max']; $max = WordPressVersion::fromString( $wp_version['max'] ); if ( ! $max ) { + // @TODO: Reuse this error message + // 'Unrecognized WordPress version. Please use "latest", a URL, or a numeric version such as "6.2", "6.0.1", "6.2-beta1", or "6.2-RC1"' throw new BlueprintExecutionException( 'Invalid WordPress version string in wordpressVersion.max: ' . $wp_version['max'] ); } } @@ -486,6 +515,14 @@ private function validateBlueprint(): void { // correctly. The actual version check for WordPress is done in // NewSiteResolver and ExistingSiteResolver. } + + // Validate the override constraint if it was set + if ( $this->wpVersionConstraint ) { + $wpConstraintErrors = $this->wpVersionConstraint->validate(); + if ( ! empty( $wpConstraintErrors ) ) { + throw new BlueprintExecutionException( 'Invalid WordPress version constraint from CLI override: ' . implode( '; ', $wpConstraintErrors ) ); + } + } } private function createExecutionPlan(): array { @@ -626,9 +663,9 @@ private function createExecutionPlan(): array { // @TODO: Make sure this doesn't get included twice in the execution plan, // e.g. if the Blueprint specified this step manually. if ( $step instanceof ImportContentStep ) { - if($this->configuration->isRunningAsPhar()) { - throw new InvalidArgumentException( '@TODO: Importing content is not supported when running as phar.' ); - } else { + // if($this->configuration->isRunningAsPhar()) { + // throw new InvalidArgumentException( '@TODO: Importing content is not supported when running as phar.' ); + // } else { $libraries_phar_path = __DIR__ . '/../../dist/php-toolkit.phar'; if(!file_exists($libraries_phar_path)) { throw new InvalidArgumentException( @@ -641,7 +678,7 @@ private function createExecutionPlan(): array { 'filename' => 'php-toolkit.phar', 'content' => file_get_contents( $libraries_phar_path ) ] ) ); - } + // } array_unshift( $plan, $this->createStepObject( 'writeFiles', [ 'files' => [ 'php-toolkit.phar' => $source, @@ -920,14 +957,6 @@ static function () { return new WriteFilesStep( $files ); - case 'runPHP': - return new RunPHPStep( - $this->createDataReference( [ - 'filename' => 'run-php.php', - 'content' => $data['code'], - ] ), - $data['env'] ?? [] - ); case 'unzip': $zipFile = $this->createDataReference( $data['zipFile'], [ ExecutionContextPath::class ] ); diff --git a/components/Blueprints/RunnerConfiguration.php b/components/Blueprints/RunnerConfiguration.php index 5f532501..f02a04ab 100644 --- a/components/Blueprints/RunnerConfiguration.php +++ b/components/Blueprints/RunnerConfiguration.php @@ -23,7 +23,7 @@ class RunnerConfiguration { /** * @var string */ - private $mode = 'create-new-site'; // or apply-to-existing-site + private $mode = Runner::EXECUTION_MODE_CREATE_NEW_SITE; /** * @var string */ diff --git a/components/Blueprints/Runtime.php b/components/Blueprints/Runtime.php index 17c88a69..cdfa1419 100644 --- a/components/Blueprints/Runtime.php +++ b/components/Blueprints/Runtime.php @@ -75,7 +75,7 @@ public function __construct( array $blueprint, string $tempRoot, DataReference $wpCliReference, - string $executionContextRoot + ?string $executionContextRoot=null ) { $this->targetFs = $targetFs; $this->configuration = $configuration; @@ -87,7 +87,7 @@ public function __construct( $this->executionContextRoot = $executionContextRoot; } - public function getExecutionContextRoot(): string { + public function getExecutionContextRoot(): ?string { return $this->executionContextRoot; } @@ -231,8 +231,9 @@ public function evalPhpCodeInSubProcess( $process = $this->createPhpSubProcess( $code, $env, $input, $timeout ); $process->mustRun(); + $output = $process->getOutputStream(Process::OUTPUT_FILE)->consume_all(); return new EvalResult( - $process->getOutputStream(Process::OUTPUT_FILE)->consume_all(), + $output, $process ); } @@ -262,7 +263,7 @@ public function createPhpSubProcess( $phpBinary = null; if ( getenv('PHP_BINARY') ) { $phpBinary = getenv('PHP_BINARY'); - } elseif ( PHP_SAPI === 'cli' && isset($_SERVER['argv'][0]) ) { + } elseif ( PHP_BINARY ) { $phpBinary = PHP_BINARY; } else { $phpBinary = 'php'; diff --git a/components/Blueprints/SiteResolver/NewSiteResolver.php b/components/Blueprints/SiteResolver/NewSiteResolver.php index dd4f87a0..d874ddce 100644 --- a/components/Blueprints/SiteResolver/NewSiteResolver.php +++ b/components/Blueprints/SiteResolver/NewSiteResolver.php @@ -9,6 +9,7 @@ use WordPress\Blueprints\Runtime; use WordPress\Blueprints\VersionStrings\VersionConstraint; use WordPress\HttpClient\Client; +use WordPress\HttpClient\Request; use WordPress\Zip\ZipFilesystem; use function WordPress\Filesystem\copy_between_filesystems; @@ -66,6 +67,10 @@ static public function resolve( Runtime $runtime, Tracker $progress, ?VersionCon // If SQLite integration zip provided, unzip into appropriate folder if ( $runtime->getConfiguration()->getDatabaseEngine() === 'sqlite' ) { + /* + * @TODO: Ensure DB_NAME gets defined in wp-config.php before installing the SQLite plugin. + */ + $progress['resolve_assets']->setCaption( 'Downloading SQLite integration plugin' ); $resolved = $runtime->resolve( $assets['sqlite-integration'] ); if ( ! $resolved instanceof File ) { @@ -96,22 +101,7 @@ static public function resolve( Runtime $runtime, Tracker $progress, ?VersionCon // Technically, this is a "new site" resolver, but it's entirely possible // the developer-provided WordPress zip already has a sqlite database with the // a WordPress site installed.. - $installCheck = $runtime->evalPhpCodeInSubProcess( - <<<'PHP' -outputFileContent; - - if ( trim( $installCheck ) !== '1' ) { + if ( ! self::isWordPressInstalled( $runtime, $progress ) ) { if ( ! $targetFs->exists( '/wp-config.php' ) ) { if ( $targetFs->exists( 'wp-config-sample.php' ) ) { $targetFs->copy( 'wp-config-sample.php', 'wp-config.php' ); @@ -129,6 +119,8 @@ static public function resolve( Runtime $runtime, Tracker $progress, ?VersionCon $wp_cli_path, 'core', 'install', + '--path=' . $runtime->getConfiguration()->getTargetSiteRoot(), + // For Docker compatibility. If we got this far, Blueprint runner was already // allowed to run as root. '--allow-root', @@ -140,10 +132,39 @@ static public function resolve( Runtime $runtime, Tracker $progress, ?VersionCon '--skip-email', ] ); $process->mustRun(); + + if ( ! self::isWordPressInstalled( $runtime, $progress ) ) { + // @TODO: This breaks in Playground CLI + throw new BlueprintExecutionException( 'WordPress installation failed' ); + } } $progress->finish(); } + static private function isWordPressInstalled( Runtime $runtime, Tracker $progress ) { + $installCheck = $runtime->evalPhpCodeInSubProcess( + <<<'PHP' + $runtime->getConfiguration()->getTargetSiteRoot(), + ], + null, + 5 + )->outputFileContent; + + return trim( $installCheck ) === '1'; + } + static private function resolveWordPressZipUrl( Client $client, string $version_string ): string { if ( $version_string === 'latest' ) { return 'https://wordpress.org/latest.zip'; @@ -160,13 +181,19 @@ static private function resolveWordPressZipUrl( Client $client, string $version_ return 'https://wordpress.org/nightly-builds/wordpress-latest.zip'; } - $latestVersions = $client->fetch( 'https://api.wordpress.org/core/version-check/1.7/?channel=beta' )->json(); + $latestVersions = $client->fetch( new Request( 'https://api.wordpress.org/core/version-check/1.7/?channel=beta' ) )->json(); $latestVersions = array_filter( $latestVersions['offers'], function ( $v ) { return $v['response'] === 'autoupdate'; } ); - + $latestNonBeta = null; + foreach ( $latestVersions as $apiVersion ) { + // Keep track of the first non-beta version (which is the latest) + if ( $latestNonBeta === null && strpos( $apiVersion['version'], 'beta' ) === false ) { + $latestNonBeta = $apiVersion; + } + if ( $version_string === 'beta' && strpos( $apiVersion['version'], 'beta' ) !== false ) { return $apiVersion['download']; } elseif ( @@ -189,6 +216,11 @@ static private function resolveWordPressZipUrl( Client $client, string $version_ return $apiVersion['download']; } } + + // If we're looking for beta but no beta was found, return latest + if ( $version_string === 'beta' && $latestNonBeta !== null ) { + return $latestNonBeta['download']; + } /** * If we didn't get a useful match in the API response, it could be version that's not diff --git a/components/Blueprints/Steps/ImportContentStep.php b/components/Blueprints/Steps/ImportContentStep.php index 40ff4432..4f4da363 100644 --- a/components/Blueprints/Steps/ImportContentStep.php +++ b/components/Blueprints/Steps/ImportContentStep.php @@ -68,7 +68,7 @@ private function importWxr( Runtime $runtime, array $content_definition, Tracker 'wxr', - 'execution_context_root' => getenv('EXECUTION_CONTEXT'), + 'execution_context_root' => getenv('EXECUTION_CONTEXT') ? getenv('EXECUTION_CONTEXT') : null, 'source' => json_decode(getenv('DATA_SOURCE_DEFINITION'), true), // @TODO: Support arbitrary media URLs to enable fetching assets during import. // 'media_url' => 'https://pd.w.org/' @@ -99,8 +99,11 @@ private function importWxr( Runtime $runtime, array $content_definition, Tracker break; case 'error': throw new BlueprintExecutionException( $data['message'] ); + case 'completion': + $progress->finish(); + break; default: - throw new BlueprintExecutionException( 'Unknown messagetype: ' . $data['type'] ); + throw new BlueprintExecutionException( 'Unknown message type: ' . $data['type'] ); } } diff --git a/components/Blueprints/Steps/InstallPluginStep.php b/components/Blueprints/Steps/InstallPluginStep.php index dc0c2d80..b76a5c2e 100644 --- a/components/Blueprints/Steps/InstallPluginStep.php +++ b/components/Blueprints/Steps/InstallPluginStep.php @@ -154,7 +154,7 @@ public function error( $errors ) { public function feedback( $string, ...$args ) { // For debugging - error_log( sprintf( $string, ...$args ) ); + fwrite( STDERR, sprintf( $string, ...$args ) . "\n" ); } public function header() { @@ -191,41 +191,34 @@ public function after( $title = '' ) { if ( ! empty( $admins ) ) { wp_set_current_user( $admins[0]->ID ); } else { - error_log( "No admin user found to perform plugin installation." ); + fwrite( STDERR, "No admin user found to perform plugin installation." . "\n" ); exit( 1 ); } $plugin_zip_path = getenv( 'PLUGIN_ZIP_PATH' ); if ( ! $plugin_zip_path ) { - error_log( "PLUGIN_ZIP_PATH environment variable not set." ); + fwrite( STDERR, "PLUGIN_ZIP_PATH environment variable not set." . "\n" ); exit( 1 ); } if ( ! file_exists( $plugin_zip_path ) ) { - error_log( "Plugin zip file not found at " . $plugin_zip_path ); + fwrite( STDERR, "Plugin zip file not found at " . $plugin_zip_path . "\n" ); exit( 1 ); } // List files from the plugin zip $zip = new ZipArchive(); if ( $zip->open( $plugin_zip_path ) !== true ) { - error_log( "Failed to open plugin zip file: " . $plugin_zip_path ); + fwrite( STDERR, "Failed to open plugin zip file: " . $plugin_zip_path . "\n" ); exit( 1 ); } -error_log( "Plugin zip contents:" ); +fwrite( STDERR, "Plugin zip contents:" . "\n" ); for ( $i = 0; $i < $zip->numFiles; $i ++ ) { $filename = $zip->getNameIndex( $i ); $stats = $zip->statIndex( $i ); $size = $stats['size']; $is_dir = substr( $filename, - 1 ) === '/'; - - error_log( sprintf( - "%s%s (%s bytes)", - $filename, - $is_dir ? " [directory]" : "", - $size - ) ); } // Extract plugin slug from the zip file @@ -244,7 +237,7 @@ public function after( $title = '' ) { // Make sure the destination directory is writable $wp_plugin_dir = WP_PLUGIN_DIR; if ( ! is_writable( $wp_plugin_dir ) ) { - error_log( "Plugin directory is not writable: " . $wp_plugin_dir ); + fwrite( STDERR, "Plugin directory is not writable: " . $wp_plugin_dir . "\n" ); // Try to fix permissions @chmod( $wp_plugin_dir, 0755 ); if ( ! is_writable( $wp_plugin_dir ) ) { @@ -269,7 +262,7 @@ public function after( $title = '' ) { // Create the directory $GLOBALS['wp_filesystem']->mkdir( $target_directory ); - error_log( "Created target directory: " . $target_directory ); + fwrite( STDERR, "Created target directory: " . $target_directory . "\n" ); } // Install the plugin @@ -281,22 +274,22 @@ public function after( $title = '' ) { // Check for filesystem errors if ( $GLOBALS['wp_filesystem']->errors->has_errors() ) { foreach ( $GLOBALS['wp_filesystem']->errors->get_error_messages() as $message ) { - error_log( "Filesystem error: " . $message ); + fwrite( STDERR, "Filesystem error: " . $message . "\n" ); } exit( 1 ); } if ( is_wp_error( $result ) ) { - error_log( "Failed to install plugin (1): " . $result->get_error_message() ); + fwrite( STDERR, "Failed to install plugin (1): " . $result->get_error_message() . "\n" ); exit( 1 ); } if ( $result === false || $result === null ) { // Check skin for errors if $result is not specific. if ( isset( $skin->result ) && is_wp_error( $skin->result ) ) { - error_log( "Failed to install plugin (2): " . $skin->result->get_error_message() ); + fwrite( STDERR, "Failed to install plugin (2): " . $skin->result->get_error_message() . "\n" ); } else { - error_log( "Failed to install plugin for an unknown reason." ); + fwrite( STDERR, "Failed to install plugin for an unknown reason." . "\n" ); } exit( 1 ); } @@ -304,14 +297,14 @@ public function after( $title = '' ) { // Installation successful, find the main plugin file. $plugin_folder_name = ! empty( $plugin_slug ) ? $plugin_slug : ( $upgrader->result['destination_name'] ?? null ); if ( ! $plugin_folder_name ) { - error_log( "Could not determine plugin folder name after installation." ); + fwrite( STDERR, "Could not determine plugin folder name after installation." . "\n" ); exit( 1 ); } // Get all plugins within the newly installed folder. $plugins_in_folder = get_plugins( '/' . $plugin_folder_name ); if ( empty( $plugins_in_folder ) ) { - error_log( "Could not find any plugin files in the installed folder: " . $plugin_folder_name ); + fwrite( STDERR, "Could not find any plugin files in the installed folder: " . $plugin_folder_name . "\n" ); exit( 1 ); } // The key of the first plugin entry is the relative path needed for activation. diff --git a/components/Blueprints/Steps/WPCLIStep.php b/components/Blueprints/Steps/WPCLIStep.php index e53674bb..bfed9802 100644 --- a/components/Blueprints/Steps/WPCLIStep.php +++ b/components/Blueprints/Steps/WPCLIStep.php @@ -43,6 +43,7 @@ public function run( Runtime $runtime, Tracker $tracker ) { // For Docker compatibility. If we got this far, the Blueprint runner was already // allowed to run as root. '--allow-root', + '--path=' . $runtime->getConfiguration()->getTargetSiteRoot(), substr($command, 3), ]); $process = $runtime->startShellCommand( $command ); diff --git a/components/Blueprints/Steps/scripts/import-content.php b/components/Blueprints/Steps/scripts/import-content.php index bdbe1f32..d08092ce 100644 --- a/components/Blueprints/Steps/scripts/import-content.php +++ b/components/Blueprints/Steps/scripts/import-content.php @@ -10,6 +10,7 @@ use WordPress\DataLiberation\EntityReader\EPubEntityReader; use WordPress\DataLiberation\EntityReader\FilesystemEntityReader; use WordPress\DataLiberation\EntityReader\WXREntityReader; +use WordPress\DataLiberation\Importer\EntityImporter; use WordPress\DataLiberation\Importer\ImportSession; use WordPress\DataLiberation\Importer\ImportUtils; use WordPress\DataLiberation\Importer\RetryFrontloadingIterator; @@ -244,10 +245,6 @@ function($event) use ($reporter) { bail_out( 'The "source" option is required.' ); } - if(!isset($options['execution_context_root'])) { - bail_out( 'The "execution_context_root" option is required.' ); - } - // Set up progress stages $mainTracker->split([ 'setup' => ['ratio' => 10, 'caption' => 'Setting up import'], @@ -263,9 +260,12 @@ function($event) use ($reporter) { $content_source = DataReference::create($options['source'], [ ExecutionContextPath::class, ]); - $execution_context = LocalFilesystem::create($options['execution_context_root']); $resolver = new DataReferenceResolver($httpClient); - $resolver->setExecutionContext($execution_context); + if(isset($options['execution_context_root']) && $options['execution_context_root'] !== '') { + $resolver->setExecutionContext( + LocalFilesystem::create($options['execution_context_root']) + ); + } $resolved_source = $resolver->resolve_uncached($content_source); $setupTracker->set(30, 'Configuring import mode'); @@ -570,6 +570,7 @@ function ( $processor ) { $importer = StreamImporter::create( $entity_reader_factory, array( + 'entity_sink' => new EntityImporter(), 'source_site_url' => $source_site_url, 'new_site_content_root_url' => NEW_SITE_CONTENT_ROOT, 'source_media_root_urls' => $options['media_url'] ?? array( $source_site_url ), diff --git a/components/Blueprints/VersionStrings/WordPressVersion.php b/components/Blueprints/VersionStrings/WordPressVersion.php index 35b3e04e..3e1c2032 100644 --- a/components/Blueprints/VersionStrings/WordPressVersion.php +++ b/components/Blueprints/VersionStrings/WordPressVersion.php @@ -46,7 +46,7 @@ class WordPressVersion implements Version { * @return $this|false|null */ static public function fromString( string $raw ) { - if(in_array($raw, ['beta','trunk'], true)) { + if(in_array($raw, ['beta','trunk','latest'], true)) { return null; } if(substr($raw, 0, 8) === 'https://') { diff --git a/components/Blueprints/Versions/Version1/V1ToV2Transpiler.php b/components/Blueprints/Versions/Version1/V1ToV2Transpiler.php index ba2bd9f6..9724f4fa 100644 --- a/components/Blueprints/Versions/Version1/V1ToV2Transpiler.php +++ b/components/Blueprints/Versions/Version1/V1ToV2Transpiler.php @@ -215,18 +215,7 @@ public function upgrade( array $validated_v1_blueprint ): array { $this->logger->warning( 'The `enableMultisite` step is not supported by the Blueprint v2 schema and will be ignored.' ); break; case 'importWordPressFiles': - if ( isset( $v1step['progress'] ) ) { - $this->logger->warning( 'The `progress` option is not supported on importWordPressFiles step and will be ignored: %s. Use the runtime configuration to set the progress bar instead.' ); - } - if ( isset( $v1step['pathInZip'] ) ) { - $this->logger->warning( 'The `pathInZip` option is not supported on importWordPressFiles step. The entire step will be ignored.' ); - } else { - /** - * wordPressFilesZip refers to a zip file with a full WordPress installation. - * It's relevant for the target site resolution stage, not as a step. - */ - $v2['wordpressVersion'] = self::convertV1ResourceToV2Reference( $v1step['wordPressFilesZip'] ); - } + $this->logger->warning( 'The `importWordPressFiles` step is not supported by the Blueprint v2 schema. The entire step will be ignored.' ); break; case 'runWpInstallationWizard': $this->logger->warning( 'The `runWpInstallationWizard` step is not supported by the Blueprint v2 schema. Provide your WordPress export URL in the top-level "wordpressVersion" key and the runner will handle the installation automatically.' ); diff --git a/components/Blueprints/Versions/Version1/schema-v1.json b/components/Blueprints/Versions/Version1/schema-v1.json index c47a2d6e..ae6bff1e 100644 --- a/components/Blueprints/Versions/Version1/schema-v1.json +++ b/components/Blueprints/Versions/Version1/schema-v1.json @@ -144,6 +144,10 @@ "description": "User to log in as. If true, logs the user in as admin/password." }, "phpExtensionBundles": { + "type": "array", + "items": { + "type": "string" + }, "deprecated": "No longer used. Feel free to remove it from your Blueprint." }, "steps": { diff --git a/components/Blueprints/bin/blueprint.php b/components/Blueprints/bin/blueprint.php index d96fe88d..98a0925a 100644 --- a/components/Blueprints/bin/blueprint.php +++ b/components/Blueprints/bin/blueprint.php @@ -36,6 +36,7 @@ use WordPress\CLI\CLI; use WordPress\Blueprints\DataReference\AbsoluteLocalPath; use WordPress\Blueprints\DataReference\DataReference; +use WordPress\Blueprints\DataReference\ExecutionContextPath; use WordPress\Blueprints\Exception\BlueprintExecutionException; use WordPress\Blueprints\Exception\PermissionsException; use WordPress\Blueprints\Logger\CLILogger; @@ -49,6 +50,190 @@ @sapi_windows_vt100_support( STDOUT, true ); } +interface ProgressReporter { + /** + * Report progress update + * + * @param float $progress Progress percentage (0-100) + * @param string $caption Progress caption/message + */ + public function reportProgress(float $progress, string $caption): void; + + /** + * Report an error + * + * @param string $message Error message + * @param \Throwable|null $exception Optional exception details + */ + public function reportError(string $message, ?\Throwable $exception = null): void; + + /** + * Report completion + * + * @param string $message Completion message + */ + public function reportCompletion(string $message): void; + + /** + * Close/cleanup the reporter + */ + public function close(): void; +} + +class TerminalProgressReporter implements ProgressReporter { + private $stdout; + private $lastProgress = -1; + private $lastCaption = ''; + private $progressBarWidth = 50; + + public function __construct() { + $this->stdout = fopen('php://stdout', 'w'); + } + + public function reportProgress(float $progress, string $caption): void { + // Don't repeat identical progress + if ($this->lastProgress === $progress && $this->lastCaption === $caption) { + return; + } + + $this->lastProgress = $progress; + $this->lastCaption = $caption; + + $percentage = min(100, max(0, $progress)); + $filled = (int)round($this->progressBarWidth * ($percentage / 100)); + $empty = $this->progressBarWidth - $filled; + + $bar = str_repeat('=', $filled); + if ($empty > 0 && $filled < $this->progressBarWidth) { + $bar .= '>'; + $bar .= str_repeat(' ', $empty - 1); + } else { + $bar .= str_repeat(' ', $empty); + } + + $status = sprintf( + "\r[%s] %3.1f%% - %s", + $bar, + $percentage, + $caption + ); + + if ($this->isTty()) { + // Clear line and write new progress + fwrite($this->stdout, "\r\033[K" . $status); + } else { + // Non-TTY, just write new line + fwrite($this->stdout, $status . "\n"); + } + fflush($this->stdout); + } + + public function reportError(string $message, ?\Throwable $exception = null): void { + $this->clearCurrentLine(); + + $errorMsg = "\033[1;31mError:\033[0m " . $message; + if ($exception) { + $errorMsg .= " (" . $exception->getMessage() . ")"; + } + + fwrite($this->stdout, $errorMsg . "\n"); + fflush($this->stdout); + } + + public function reportCompletion(string $message): void { + $this->clearCurrentLine(); + fwrite($this->stdout, "\033[1;32m" . $message . "\033[0m\n"); + fflush($this->stdout); + } + + public function close(): void { + if ($this->stdout) { + fclose($this->stdout); + } + } + + private function clearCurrentLine(): void { + if ($this->isTty()) { + fwrite($this->stdout, "\r\033[K"); + } + } + + private function isTty(): bool { + return stream_isatty($this->stdout); + } +} + +class JsonProgressReporter implements ProgressReporter { + private $outputFile; + + public function __construct() { + $outputPath = getenv('OUTPUT_FILE') ?: 'php://stdout'; + $this->outputFile = fopen($outputPath, 'w'); + } + + public function reportProgress(float $progress, string $caption): void { + $this->writeJsonMessage([ + 'type' => 'progress', + 'progress' => round($progress, 2), + 'caption' => $caption + ]); + } + + public function reportError(string $message, ?\Throwable $exception = null): void { + $errorData = [ + 'type' => 'error', + 'message' => $message + ]; + + if ($exception) { + $errorData['details'] = [ + 'exception' => get_class($exception), + 'message' => $exception->getMessage(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => $exception->getTraceAsString() + ]; + } + + $this->writeJsonMessage($errorData); + } + + public function reportCompletion(string $message): void { + $this->writeJsonMessage([ + 'type' => 'completion', + 'message' => $message + ]); + } + + public function close(): void { + if ($this->outputFile) { + fclose($this->outputFile); + } + } + + private function writeJsonMessage(array $data): void { + fwrite($this->outputFile, json_encode($data) . "\n"); + fflush($this->outputFile); + } +} + +function createProgressReporter(): ProgressReporter { + $reporter = apply_filters('blueprint.progress_reporter', null); + if ( $reporter ) { + return $reporter; + } + + // Use JSON mode if OUTPUT_FILE is set or if we're not in a TTY + if (getenv('OUTPUT_FILE') || !stream_isatty(STDOUT)) { + return new JsonProgressReporter(); + } + + return new TerminalProgressReporter(); +} + + +$progressReporter = createProgressReporter(); + // ----------------------------------------------------------------------------- // Command and option definitions // ----------------------------------------------------------------------------- @@ -71,7 +256,7 @@ 'site-url' => [ 'u', true, null, 'Public site URL (https://example.com)' ], 'site-path' => [ null, true, null, 'Target directory with WordPress install context)' ], 'execution-context' => [ 'x', true, null, 'Source directory with Blueprint context files' ], - 'mode' => [ 'm', true, 'create-new-site', 'Execution mode (create|apply)' ], + 'mode' => [ 'm', true, Runner::EXECUTION_MODE_CREATE_NEW_SITE, sprintf( 'Execution mode (%s|%s)', Runner::EXECUTION_MODE_CREATE_NEW_SITE, Runner::EXECUTION_MODE_APPLY_TO_EXISTING_SITE ) ], 'db-engine' => [ 'd', true, 'mysql', 'Database engine (mysql|sqlite)' ], 'db-host' => [ null, true, '127.0.0.1', 'MySQL host' ], 'db-user' => [ null, true, 'root', 'MySQL user' ], @@ -79,11 +264,23 @@ 'db-name' => [ null, true, 'wordpress', 'MySQL database' ], 'db-path' => [ 'p', true, 'wp.db', 'SQLite file path' ], 'truncate-new-site-directory' => [ 't', false, false, 'Delete target directory if it exists before execution' ], + /** + * @TODO: Reuse this error message removed from the Playground repo: + * + * if (!blueprintMayReadAdjacentFiles) { + * throw new ReportableError( + * `Error: Blueprint contained tried to read a local file at path "${path}" (via a resource of type "bundled"). ` + + * `Playground restricts access to local resources by default as a security measure. \n\n` + + * `You can allow this Blueprint to read files from the same parent directory by explicitly adding the ` + + * `--blueprint-may-read-adjacent-files option to your command.` + * ); + * } + */ 'allow' => [ null, true, null, 'Allowed permissions. One of: ' . implode( ', ', $supportedPermissions ) ], ] ), 'examples' => [ 'php blueprint.php exec my-blueprint.json --site-url https://mysite.test --site-path /var/www/mysite.com', - 'php blueprint.php exec my-blueprint.json --execution-context /var/www --site-url https://mysite.test --mode apply --site-path ./site', + sprintf( 'php blueprint.php exec my-blueprint.json --execution-context /var/www --site-url https://mysite.test --mode %s --site-path ./site', Runner::EXECUTION_MODE_APPLY_TO_EXISTING_SITE ), 'php blueprint.php exec my-blueprint.json --site-url https://mysite.test --site-path ./mysite --truncate-new-site-directory', ], 'aliases' => [ 'run' ], @@ -123,7 +320,7 @@ function resolveCommand( $commandArg, array $commandConfigurations ): ?string { // ----------------------------------------------------------------------------- // Command handlers // ----------------------------------------------------------------------------- -function handleExecCommand( array $positionalArgs, array $options, array $commandConfig ): void { +function handleExecCommand( array $positionalArgs, array $options, array $commandConfig, ProgressReporter $progressReporter ): void { // Check if help is requested for this command if ( $options['help'] ) { showCommandHelpMessage( 'exec', $commandConfig ); @@ -133,52 +330,49 @@ function handleExecCommand( array $positionalArgs, array $options, array $comman // Validate required options foreach ( $commandConfig['requiredOptions'] as $requiredOption ) { if ( empty( $options[ $requiredOption ] ) ) { - echo "\033[31mError:\033[0m The --$requiredOption option is required for the exec command." . PHP_EOL; + $progressReporter->reportError("The --$requiredOption option is required for the exec command."); exit( 1 ); } } // Validate required positional arguments if ( empty( $positionalArgs ) ) { - echo "\033[31mError:\033[0m A Blueprint reference must be specified as a positional argument." . PHP_EOL; + $progressReporter->reportError("A Blueprint reference must be specified as a positional argument."); exit( 1 ); } try { // Convert CLI arguments to RunnerConfiguration $config = cliArgsToRunnerConfiguration( $positionalArgs, $options ); - $config->setProgressObserver( new ProgressObserver( function ( $progress, $caption ) { - reportProgress( $progress, $caption ); + $config->setProgressObserver( new ProgressObserver( function ( $progress, $caption ) use ( $progressReporter ) { + $progressReporter->reportProgress( $progress, $caption ); } ) ); $runner = new Runner( $config ); // Execute the Blueprint - if ( $config->getExecutionMode() === 'create-new-site' ) { - echo "\033[1;32mCreating a new site\033[0m\n"; + if ( $config->getExecutionMode() === Runner::EXECUTION_MODE_CREATE_NEW_SITE ) { + $progressReporter->reportProgress(0, 'Creating a new site'); } else { - echo "\033[1;32mUpdating an existing site\033[0m\n"; + $progressReporter->reportProgress(0, 'Updating an existing site'); } - echo sprintf( " Site URL: %s\n", $config->getTargetSiteUrl() ); - echo sprintf( " Site path: %s\n", $config->getTargetSiteRoot() ); - echo sprintf( " Blueprint: %s\n", $config->getBlueprint()->get_human_readable_name() ); - echo PHP_EOL; + $progressReporter->reportProgress(0, sprintf(" Site URL: %s", $config->getTargetSiteUrl())); + $progressReporter->reportProgress(0, sprintf(" Site path: %s", $config->getTargetSiteRoot())); + $progressReporter->reportProgress(0, sprintf(" Blueprint: %s", $config->getBlueprint()->get_human_readable_name())); $runner->run(); - echo PHP_EOL; - echo sprintf( "\033[32m✔ Blueprint successfully executed.\033[0m\n" ); + $progressReporter->reportCompletion("Blueprint successfully executed."); } catch ( PermissionsException $ex ) { - echo PHP_EOL . PHP_EOL; $permission = $ex->getPermission(); $flag = RunnerConfiguration::getPermissionCliFlag( $permission ); - echo sprintf( "\033[31mPermission Error:\033[0m %s\n", $ex->getMessage() ); - echo sprintf( "\033[33mTip:\033[0m Run with \033[1m--allow=%s\033[0m to grant this permission.\n", $flag ); + $progressReporter->reportError(sprintf("Permission Error: %s", $ex->getMessage()), $ex); + $progressReporter->reportError(sprintf("Tip: Run with --allow=%s to grant this permission.", $flag)); exit( 1 ); } } -function handleHelpCommand( array $positionalArgs, array $options, array $commandConfigurations ): void { +function handleHelpCommand( array $positionalArgs, array $options, array $commandConfigurations, ProgressReporter $progressReporter ): void { if ( ! empty( $positionalArgs ) ) { $requestedCommand = $positionalArgs[0]; $resolvedCommand = resolveCommand( $requestedCommand, $commandConfigurations ); @@ -186,7 +380,7 @@ function handleHelpCommand( array $positionalArgs, array $options, array $comman if ( $resolvedCommand !== null ) { showCommandHelpMessage( $resolvedCommand, $commandConfigurations[ $resolvedCommand ] ); } else { - echo "\033[31mError:\033[0m Unknown command '$requestedCommand'.\n\n"; + $progressReporter->reportError("Unknown command '$requestedCommand'."); showGeneralHelpMessage( $commandConfigurations ); } } else { @@ -204,35 +398,49 @@ function cliArgsToRunnerConfiguration( array $positionalArgs, array $options ): $blueprint_reference = $positionalArgs[0]; $config->setBlueprint( DataReference::create( $blueprint_reference, [ AbsoluteLocalPath::class, + ExecutionContextPath::class, ] ) ); } catch ( InvalidArgumentException $e ) { - throw new InvalidArgumentException( "Invalid Blueprint reference: " . $positionalArgs[0] ); + throw new InvalidArgumentException( sprintf( "Invalid Blueprint reference: %s. Hint: paths must start with ./ or /. URLs must start with http:// or https://.", $positionalArgs[0] ) ); } if ( ! empty( $options['mode'] ) ) { - // Accept 'create-new-site' or 'apply-to-existing-site' as CLI values, map to internal values $mode = $options['mode']; - if ( $mode === 'create-new-site' ) { - $config->setExecutionMode( 'create-new-site' ); - } elseif ( $mode === 'apply-to-existing-site' ) { - $config->setExecutionMode( 'apply-to-existing-site' ); + if ( $mode === Runner::EXECUTION_MODE_CREATE_NEW_SITE ) { + $config->setExecutionMode( Runner::EXECUTION_MODE_CREATE_NEW_SITE ); + } elseif ( $mode === Runner::EXECUTION_MODE_APPLY_TO_EXISTING_SITE ) { + $config->setExecutionMode( Runner::EXECUTION_MODE_APPLY_TO_EXISTING_SITE ); + if(!empty($options['wp'])) { + throw new InvalidArgumentException( sprintf( "The --wp option cannot be used with --mode=%s. The WordPress version is whatever the existing site has.", Runner::EXECUTION_MODE_APPLY_TO_EXISTING_SITE ) ); + } } else { - throw new InvalidArgumentException( "Invalid execution mode: {$mode}. Supported modes are: create-new-site, apply-to-existing-site" ); + throw new InvalidArgumentException( sprintf( "Invalid execution mode: '{$mode}'. Supported modes are: %s", implode( ', ', Runner::EXECUTION_MODES ) ) ); } } $targetSiteRoot = $options['site-path']; if ( $options['truncate-new-site-directory'] ) { - if ( $options['mode'] !== 'create-new-site' ) { - throw new InvalidArgumentException( "--truncate-new-site-directory can only be used with --mode=create-new-site" ); + if ( $options['mode'] !== Runner::EXECUTION_MODE_CREATE_NEW_SITE ) { + throw new InvalidArgumentException( sprintf( "--truncate-new-site-directory can only be used with --mode=%s", Runner::EXECUTION_MODE_CREATE_NEW_SITE ) ); } $absoluteTargetSiteRoot = realpath( $targetSiteRoot ); if ( false === $absoluteTargetSiteRoot) { mkdir( $targetSiteRoot, 0755, true ); } else if( is_dir( $absoluteTargetSiteRoot ) ) { $fs = LocalFilesystem::create( $absoluteTargetSiteRoot ); - $fs->rmdir( '/', [ 'recursive' => true ] ); - $fs->mkdir( '/', [ 'chmod' => 0755 ] ); + // Delete all the files and directories in the target site root, but preserve the + // target directory itself. Why? In Playground CLI, `/wordpress` is likely to be a + // mount removing a mount root throws an Exception. + foreach ( $fs->ls('/') as $file ) { + if( $fs->is_dir( $file ) ) { + $fs->rmdir( $file, [ 'recursive' => true ] ); + } else { + $fs->rm( $file ); + } + } + if ( ! $fs->is_dir( '/' ) ) { + $fs->mkdir( '/', [ 'chmod' => 0755 ] ); + } } } @@ -380,33 +588,6 @@ function showCommandHelpMessage( string $command, array $commandConfig ): void { echo "\n"; } -function reportProgress( $progress, $caption ) { - static $lastLength = 0; - static $columns = null; - $output = sprintf( "[%3d%%] %s", $progress, $caption ); - $currentLength = strlen( $output ); - - // Get terminal width if possible - if ( null === $columns ) { - if ( function_exists( 'exec' ) && false !== exec( 'tput cols 2>/dev/null', $out ) ) { - $columns = (int) $out[0]; - } elseif ( function_exists( 'shell_exec' ) && ( $shellColumns = shell_exec( 'tput cols 2>/dev/null' ) ) ) { - $columns = (int) $shellColumns; - } - if ( null === $columns ) { - $columns = 80; - } - } - - // Truncate if longer than terminal width - if ( $currentLength > $columns - 1 ) { - $output = substr( $output, 0, $columns - 4 ) . '...'; - $currentLength = $columns - 1; - } - - fprintf( STDERR, "\r%s%s", $output, $currentLength < $lastLength ? str_repeat( ' ', $lastLength - $currentLength ) : '' ); - $lastLength = $currentLength; -} // ----------------------------------------------------------------------------- // Main entry @@ -425,7 +606,7 @@ function reportProgress( $progress, $caption ) { $command = resolveCommand( $commandArg, $commandConfigurations ); if ( $command === null ) { - echo "\033[31mError:\033[0m Unknown command '$commandArg'.\n\n"; + $progressReporter->reportError("Unknown command '$commandArg'."); showGeneralHelpMessage( $commandConfigurations ); exit( 1 ); } @@ -437,41 +618,39 @@ function reportProgress( $progress, $caption ) { // Dispatch to appropriate command handler switch ( $command ) { case 'exec': - handleExecCommand( $positionalArgs, $options, $commandConfigurations[ $command ] ); + handleExecCommand( $positionalArgs, $options, $commandConfigurations[ $command ], $progressReporter ); break; case 'help': - handleHelpCommand( $positionalArgs, $options, $commandConfigurations ); + handleHelpCommand( $positionalArgs, $options, $commandConfigurations, $progressReporter ); break; default: - echo "\033[31mError:\033[0m Command handler not implemented for '$command'.\n"; + $progressReporter->reportError("Command handler not implemented for '$command'."); exit( 1 ); } } catch ( BlueprintExecutionException $ex ) { - echo PHP_EOL; if ( ! $ex->schemaError ) { - echo sprintf( "\033[31mError:\033[0m %s\n", $ex->getMessage() ); + $progressReporter->reportError($ex->getMessage()); while ( $ex->getPrevious() ) { $ex = $ex->getPrevious(); - echo sprintf( "\033[31mCaused by:\033[0m %s\n", $ex->getMessage() ); + $progressReporter->reportError("Caused by: " . $ex->getMessage()); } exit( 1 ); } - echo sprintf( "\033[31mError:\033[0m %s See the validation errors below:\n", $ex->getMessage() ); + $progressReporter->reportError($ex->getMessage() . ' See the validation errors below:'); $lastPrettyPath = ''; $currentError = $ex->schemaError; while ( $currentError ) { $prettyPath = $currentError->getPrettyPath(); if ( $prettyPath !== $lastPrettyPath ) { - echo sprintf( "\033[31m%s\033[0m: \n", $prettyPath ); + $progressReporter->reportError($prettyPath . ":"); } - echo $currentError->message . PHP_EOL; + $progressReporter->reportError($currentError->message); $currentError = $currentError->getMostProbableCause(); $lastPrettyPath = $prettyPath; } exit( 1 ); } catch ( Exception $ex ) { - echo sprintf( "\033[31mError:\033[0m %s\n", $ex->getMessage() ); - echo "Try 'help' for usage.\n"; + $progressReporter->reportError($ex->getMessage(), $ex); exit( 1 ); } diff --git a/components/ByteStream/ReadStream/BaseByteReadStream.php b/components/ByteStream/ReadStream/BaseByteReadStream.php index fa440802..7fac202a 100644 --- a/components/ByteStream/ReadStream/BaseByteReadStream.php +++ b/components/ByteStream/ReadStream/BaseByteReadStream.php @@ -7,7 +7,7 @@ abstract class BaseByteReadStream implements ByteReadStream { - const CHUNK_SIZE = 100;// 64 * 1024; + const CHUNK_SIZE = 64 * 1024; protected $buffer_size = 2048; diff --git a/components/DataLiberation/Importer/EntityImporter.php b/components/DataLiberation/Importer/EntityImporter.php index 21d94b2d..879179e9 100644 --- a/components/DataLiberation/Importer/EntityImporter.php +++ b/components/DataLiberation/Importer/EntityImporter.php @@ -144,6 +144,20 @@ public function import_entity( ImportEntity $entity ) { } public function import_site_option( $data ) { + /** + * Ignore site URL options. There doesn't seem to be a compelling use-case for + * overwriting the site URL during a content import. For example, WXR files may + * specify a site URL (typically of the source site) and it is emitted as a + * siteurl option. Is that a good reason to change the target site URL, trigger + * a redirect, and very likely break the entire site? No. + * + * We may need to revisit this approach if this class is ever used to import + * from data sources different than static content files, e.g. a database dump. + */ + if($data['option_name'] === 'siteurl' || $data['option_name'] === 'home') { + return; + } + $this->logger->info( sprintf( /* translators: %s: option name */ @@ -603,7 +617,7 @@ public function import_post( $data ) { * @param array $comments Raw comment data, already processed by {@see process_comments}. * @param array $terms Raw term data, already processed. */ - do_action( 'wxr_importer_process_failed_post', $post_id, $data, $meta, $comments, $terms ); + do_action( 'wxr_importer_process_failed_post', $post_id, $data, $meta ); return false; } @@ -737,7 +751,7 @@ protected function process_menu_item_meta( $post_id, $data, $meta ) { default: // associated object is missing or not imported yet, we'll retry later - $this->missing_menu_items[] = $item; + // $this->missing_menu_items[] = $item; $this->logger->debug( 'Unknown menu item type' ); break; } @@ -1203,7 +1217,7 @@ class Logger { * @param string $message Message to log */ public function debug( $message ) { - // echo( '[DEBUG] ' . $message ); + echo( '[DEBUG] ' . $message . "\n" ); } /** @@ -1212,7 +1226,7 @@ public function debug( $message ) { * @param string $message Message to log */ public function info( $message ) { - // echo( '[INFO] ' . $message ); + echo( '[INFO] ' . $message . "\n" ); } /** @@ -1221,7 +1235,7 @@ public function info( $message ) { * @param string $message Message to log */ public function warning( $message ) { - echo( '[WARNING] ' . $message ); + echo( '[WARNING] ' . $message . "\n" ); } /** @@ -1230,7 +1244,7 @@ public function warning( $message ) { * @param string $message Message to log */ public function error( $message ) { - echo( '[ERROR] ' . $message ); + echo( '[ERROR] ' . $message . "\n" ); } /** @@ -1239,6 +1253,6 @@ public function error( $message ) { * @param string $message Message to log */ public function notice( $message ) { - // echo( '[NOTICE] ' . $message ); + echo( '[NOTICE] ' . $message . "\n" ); } } diff --git a/components/DataLiberation/Importer/StreamImporter.php b/components/DataLiberation/Importer/StreamImporter.php index cbf86b62..f7a3cf98 100644 --- a/components/DataLiberation/Importer/StreamImporter.php +++ b/components/DataLiberation/Importer/StreamImporter.php @@ -321,7 +321,9 @@ protected function __construct( ) { $this->entity_reader_factory = $entity_reader_factory; $this->options = $options; - $this->set_source_site_url( $options['source_site_url'] ); + if ( ! empty( $options['source_site_url'] ) ) { + $this->set_source_site_url( $options['source_site_url'] ); + } if ( isset( $options['source_media_root_urls'] ) ) { foreach ( $options['source_media_root_urls'] as $source_media_root_url ) { @@ -714,7 +716,7 @@ protected function frontload_next_entity() { return true; } - protected function get_current_entity() { + public function get_current_entity() { $entity = $this->entity_iterator->current(); $entity = apply_filters( 'data_liberation.stream_importer.preprocess_entity', diff --git a/components/DataLiberation/Tests/StreamImporterTest.php b/components/DataLiberation/Tests/StreamImporterTest.php index aa93823e..12fd1b2e 100644 --- a/components/DataLiberation/Tests/StreamImporterTest.php +++ b/components/DataLiberation/Tests/StreamImporterTest.php @@ -1,7 +1,12 @@ markTestSkipped( 'Test only runs in Playground' ); + // $this->markTestSkipped( 'Test only runs in Playground' ); } } @@ -40,6 +45,82 @@ public function test_import_simple_wxr() { $this->assertTrue( $import ); } + /** + * + */ + public function test_stylish_press_local_file() { + $uploads_path = sys_get_temp_dir() . '/uploads'; + @mkdir( $uploads_path, 0777, true ); + + $sink = new class() { + public $imported_entities = []; + public $imported_attachments = []; + + public function import_entity( $entity ) { + $this->imported_entities[] = $entity; + return true; + } + public function import_attachment( $filepath, $post_id = null ) { + $this->imported_attachments[] = $filepath; + return true; + } + }; + + $importer = StreamImporter::create_for_wxr_file( __DIR__ . '/wxr/stylish-press.xml', [ + 'new_site_content_root_url' => 'http://127.0.0.1:9400', + 'new_media_root_url' => 'http://127.0.0.1:9400/wp-content/uploads', + 'uploads_path' => $uploads_path, + 'entity_sink' => $sink + ] ); + while ( $importer->next_step() || $importer->advance_to_next_stage() ) { + // noop + } + $this->assertCount( 10, $sink->imported_entities ); + } + + /** + * + */ + public function test_stylish_press_remote_stream() { + $uploads_path = sys_get_temp_dir() . '/uploads'; + @mkdir( $uploads_path, 0777, true ); + + $sink = new class() { + public $imported_entities = []; + public $imported_attachments = []; + + public function import_entity( $entity ) { + $this->imported_entities[] = $entity; + return true; + } + public function import_attachment( $filepath, $post_id = null ) { + $this->imported_attachments[] = $filepath; + return true; + } + }; + + $entity_reader_factory = function ( $cursor ) { + $stream = new RequestReadStream(new Request( + 'https://raw.githubusercontent.com/wordpress/blueprints/trunk/blueprints/stylish-press/site-content.wxr' + )); + return WXREntityReader::create( + $stream, + $cursor + ); + }; + + $importer = StreamImporter::create( $entity_reader_factory, [ + 'new_site_content_root_url' => 'http://127.0.0.1:9400', + 'new_media_root_url' => 'http://127.0.0.1:9400/wp-content/uploads', + 'uploads_path' => $uploads_path, + 'entity_sink' => $sink + ] ); + while ( $importer->next_step() || $importer->advance_to_next_stage() ) { + // noop + } + $this->assertCount( 10, $sink->imported_entities ); + } + public function test_frontloading() { $wxr_path = __DIR__ . '/wxr/frontloading-1-attachment.xml'; $importer = StreamImporter::create_for_wxr_file( $wxr_path ); diff --git a/components/DataLiberation/Tests/WXRReaderTest.php b/components/DataLiberation/Tests/WXRReaderTest.php index df3e982c..96544be8 100644 --- a/components/DataLiberation/Tests/WXRReaderTest.php +++ b/components/DataLiberation/Tests/WXRReaderTest.php @@ -3,6 +3,9 @@ use PHPUnit\Framework\TestCase; use WordPress\ByteStream\ReadStream\FileReadStream; use WordPress\DataLiberation\EntityReader\WXREntityReader; +use WordPress\DataLiberation\ImportEntity; +use WordPress\HttpClient\ByteStream\RequestReadStream; +use WordPress\HttpClient\Request; class WXRReaderTest extends TestCase { @@ -274,6 +277,99 @@ public function test_attachments() { ); } + public function test_stylish_press_wxr_local() { + $importer = WXREntityReader::create(); + $importer->append_bytes( file_get_contents( __DIR__ . '/wxr/stylish-press.xml' ) ); + $importer->input_finished(); + $this->assert_stylish_press_wxr( $importer ); + } + + public function test_stylish_press_wxr_remote() { + $importer = WXREntityReader::create(); + $importer->connect_upstream( + new RequestReadStream(new Request( + 'https://raw.githubusercontent.com/wordpress/blueprints/trunk/blueprints/stylish-press/site-content.wxr' + )) + ); + $this->assert_stylish_press_wxr( $importer ); + } + + private function assert_stylish_press_wxr( $importer ) { + $this->assertTrue( $importer->next_entity() ); + $this->assert_entity_equals( + new ImportEntity( 'site_option', array( + 'option_name' => 'blogname', + 'option_value' => 'Stylish Press', + ) ), + $importer->get_entity() + ); + + $this->assertTrue( $importer->next_entity() ); + $this->assert_entity_equals( + new ImportEntity( 'site_option', array( + 'option_name' => 'siteurl', + 'option_value' => 'http://www.stylishpress.wordpress.org' + ) ), + $importer->get_entity() + ); + + $this->assertTrue( $importer->next_entity() ); + $this->assert_entity_equals( + new ImportEntity( 'site_option', array( + 'option_name' => 'home', + 'option_value' => 'http://www.stylishpress.wordpress.org' + ) ), + $importer->get_entity() + ); + + $this->assertTrue( $importer->next_entity() ); + $this->assert_entity_equals( + new ImportEntity( 'user', array( + 'ID' => 1, + 'user_login' => 'admin', + 'user_email' => 'admin@stylishpress.wordpress.org', + 'display_name' => 'Admin', + 'first_name' => 'John', + 'last_name' => 'Doe', + ) ), + $importer->get_entity() + ); + + $this->assertTrue( $importer->next_entity() ); + $entity = $importer->get_entity(); + $this->assertEquals( 'post', $entity->get_type() ); + $this->assertEquals( 'page', $entity->get_data()['post_type'] ); + $this->assertEquals( 'home', $entity->get_data()['post_name'] ); + $this->assertEquals( 'publish', $entity->get_data()['post_status'] ); + $this->assertEquals( '1', $entity->get_data()['post_id'] ); + $this->assertEquals( 'Homepage', $entity->get_data()['post_title'] ); + + $this->assertTrue( $importer->next_entity() ); + $this->assert_entity_equals( + new ImportEntity( 'post_meta', array( + 'meta_key' => '_edit_last', + 'meta_value' => '1', + 'post_id' => 1 + ) ), + $importer->get_entity() + ); + + $this->assertTrue( $importer->next_entity() ); + + $entity = $importer->get_entity(); + $this->assertEquals( 'post', $entity->get_type() ); + $this->assertEquals( 'page', $entity->get_data()['post_type'] ); + $this->assertEquals( 'the-stylish-story', $entity->get_data()['post_name'] ); + $this->assertEquals( 'publish', $entity->get_data()['post_status'] ); + $this->assertEquals( '2', $entity->get_data()['post_id'] ); + $this->assertEquals( 'The Stylish Story', $entity->get_data()['post_title'] ); + } + + private function assert_entity_equals( $expected, $actual ) { + $this->assertEquals( $expected->get_type(), $actual->get_type() ); + $this->assertEquals( $expected->get_data(), $actual->get_data() ); + } + public function test_terms() { $importer = WXREntityReader::create(); $importer->append_bytes( diff --git a/components/DataLiberation/Tests/wxr/stylish-press.xml b/components/DataLiberation/Tests/wxr/stylish-press.xml new file mode 100644 index 00000000..f1358247 --- /dev/null +++ b/components/DataLiberation/Tests/wxr/stylish-press.xml @@ -0,0 +1,279 @@ + + + + Stylish Press + http://www.stylishpress.wordpress.org + Fashion-forward apparel for everyone! + Tue, 05 Jun 2024 12:00:00 +0000 + en-US + 1.2 + http://www.stylishpress.wordpress.org + http://www.stylishpress.wordpress.org + + + 1 + admin + admin@stylishpress.wordpress.org + + + + + + + + Homepage + http://www.stylishpress.wordpress.org/home + Tue, 05 Jun 2024 12:00:00 +0000 + + + http://www.stylishpress.wordpress.org/?p=1 + + +
+ +
+ +

Welcome to Stylish Press

+ + +

Discover the latest in fashion and style.

+ +
+
+ + + +
+ +
+ +
Trendy Outfit
+ +
+ + +
+ +

At Stylish Press, we're dedicated to bringing you the latest trends in fashion. Our collection is carefully curated to ensure you look your best, whether you're hitting the streets or the runway. Explore our site to find out more about our latest collections, fashion tips, and exclusive offers.

+ + +

Browse through our diverse range of apparel, featuring everything from chic tops to stylish bottoms, and accessories that complete your look. Our pieces are designed to make you feel confident and fashionable, day or night.

+ +
+ +
+ + + + + + + +

Our Mission

+ + +

Stylish Press was founded with a firm belief that fashion should be accessible, inclusive, and always evolving. We aim to empower our customers to express themselves through bold and contemporary designs that speak volumes about their unique style.

+ + ]]>
+ + 1 + 2024-06-05 12:00:00 + 2024-06-05 12:00:00 + closed + closed + home + publish + page + 0 + 1 + + _edit_last + 1 + +
+ + + + The Stylish Story + http://www.stylishpress.wordpress.org/the-stylish-story + Tue, 05 Jun 2024 12:00:00 +0000 + + + http://www.stylishpress.wordpress.org/?p=2 + + +
+ +
+ +

The Stylish Story

+ +
+
+ + + +

Our Beginnings

+ + +

Stylish Press was founded in 2020 by a group of fashion enthusiasts who wanted to bring a fresh perspective to the fashion industry. We began as a small online store, focusing on minimalist designs with a bold twist. Our unique approach quickly gained a loyal following, and we have been expanding our collection ever since.

+ + + + + + + + + + + +

Our Commitment

+ + +

We believe in sustainable fashion practices and ethical sourcing. Every piece in our collection is made with care, ensuring minimal impact on the environment. Our commitment to quality craftsmanship means that each item is built to last, keeping you stylish and sustainable.

+ + + +
+

"Fashion is not just about looking good, it's about feeling good and doing good." - Founders of Stylish Press

+
+ + + +

Join us on our journey to revolutionize the fashion industry, one stylish piece at a time. Together, we can make fashion more inclusive, innovative, and inspiring.

+ + ]]>
+ + 2 + 2024-06-05 12:00:00 + 2024-06-05 12:00:00 + closed + closed + the-stylish-story + publish + page + 0 + 2 + + _edit_last + 1 + +
+ + + + Connect + http://www.stylishpress.wordpress.org/contact-us + Tue, 05 Jun 2024 12:00:00 +0000 + + + http://www.stylishpress.wordpress.org/?p=5 + + +
+ +
+ +

Connect with Us

+ +
+
+ + + +

We love hearing from our customers! Whether you have questions about our products, need styling advice, or just want to share your own fashion journey, we're here for you. Reach out to us through our various contact points listed below, and let’s stay connected.

+ + + +
+ +
+ +
+ + +
+ +

Find Us Here

+ + +

Email: support@stylishpress.wordpress.org

+ + +

Phone: +1 (234) 567-890

+ + +

Address: 123 Fashion Street, Trend City, ST 56789

+ + +

Follow Us

+ + + + +
+ +
+ + + +
+

"Fashion is instant language." - Miuccia Prada

+
+ + + +

Connect with Stylish Press today and become a part of our vibrant community where fashion meets passion.

+ + ]]>
+ + 5 + 2024-06-05 12:00:00 + 2024-06-05 12:00:00 + closed + closed + contact-us + publish + page + 0 + 5 + + _edit_last + 1 + +
+ +
+
\ No newline at end of file diff --git a/components/Filesystem/LocalFilesystem.php b/components/Filesystem/LocalFilesystem.php index e7270524..58bb90d0 100644 --- a/components/Filesystem/LocalFilesystem.php +++ b/components/Filesystem/LocalFilesystem.php @@ -39,7 +39,7 @@ public static function create( $root = null ) { if ( ! is_dir( $root ) ) { if ( false === mkdir( $root, 0755, true ) ) { - throw new FilesystemException( sprintf( 'Root directory did not exist and could not be created: %s', $root ) ); + throw new FilesystemException( sprintf( 'Root directory did not exist and could not be created: %s', var_export($root, true) ) ); } } diff --git a/components/Git/GitRemote.php b/components/Git/GitRemote.php index e5677534..091c4eb0 100644 --- a/components/Git/GitRemote.php +++ b/components/Git/GitRemote.php @@ -533,8 +533,11 @@ private function http_request( $path, $postData = null, $headers = array() ) { $response = $reader->await_response(); if ( $response->status_code > 299 || $response->status_code < 200 ) { - $reader->pull( 100 ); - throw new GitRemoteException( 'HTTP request failed with status code ' . $response->status_code . '. First 100 body bytes: ' . $reader->peek( 100 ) ); + // GitHub sometimes responds with 100 status code when the request is successful. + if ( $response->status_code !== 100 ) { + $reader->pull( 100 ); + throw new GitRemoteException( 'HTTP request failed with status code ' . $response->status_code . '. First 100 body bytes: ' . $reader->peek( 100 ) ); + } } return $reader; diff --git a/components/HttpClient/ByteStream/RequestReadStream.php b/components/HttpClient/ByteStream/RequestReadStream.php index 9f3ee684..f0c691fe 100644 --- a/components/HttpClient/ByteStream/RequestReadStream.php +++ b/components/HttpClient/ByteStream/RequestReadStream.php @@ -118,13 +118,29 @@ private function pull_until_event( $options = array() ) { $content_length = $response->get_header( 'Content-Length' ); if ( null !== $content_length ) { /** - * Set the content-length based on the header, but make sure it stays null - * when the Content-Length header is not set. + * Best-effort attempt to guess the content-length of the response. * - * Important: Don't set the content-length to 0 if the header is missing! This - * would tell the streaming machinery there's no body to consume. + * Web servers often respond with a combination of Content-Length + * and Content-Encoding. For example, a 16kb text file may be compressed + * to 4kb with gzip and served with a Content-Encoding of `gzip` and a + * Content-Length of 4KB. + * + * If we just use that value, we'd truncate a 16KB body stream with at a + * Content-Length of 4KB. + * + * To correct that behavior, we're discarding the Content-Length header when + * it's used alongside a compressed response stream. */ - $this->remote_file_length = (int) $content_length; + if ( ! $response->get_header( 'Content-Encoding' ) ) { + /** + * Set the content-length based on the header, but make sure it stays null + * when the Content-Length header is not set. + * + * Important: Don't set the content-length to 0 if the header is missing! This + * would tell the streaming machinery there's no body to consume. + */ + $this->remote_file_length = (int) $content_length; + } } if ( $stop_at_event === Client::EVENT_GOT_HEADERS ) { return true; diff --git a/components/HttpClient/Client.php b/components/HttpClient/Client.php index 5ee7af08..4b8d2735 100644 --- a/components/HttpClient/Client.php +++ b/components/HttpClient/Client.php @@ -37,14 +37,14 @@ class Client { public function __construct( $options = array() ) { $this->state = new ClientState( $options ); if(empty($options['transport']) || $options['transport'] === 'auto') { - $options['transport'] = extension_loaded( 'curl' ) ? 'curl' : 'socket'; + $options['transport'] = extension_loaded( 'curl' ) ? 'curl' : 'sockets'; } switch ( $options['transport'] ) { case 'curl': $transport = new CurlTransport( $this->state ); break; - case 'socket': + case 'sockets': $transport = new SocketTransport( $this->state ); break; default: @@ -74,7 +74,10 @@ public function __construct( $options = array() ) { * * @return RequestReadStream */ - public function fetch( Request $request, array $options = [] ) { + public function fetch( $request, array $options = [] ) { + if(is_string($request)) { + $request = new Request($request); + } return new RequestReadStream( $request, array_merge( [ 'client' => $this ], diff --git a/components/XML/XMLProcessor.php b/components/XML/XMLProcessor.php index 3f70f707..c3cf1f16 100644 --- a/components/XML/XMLProcessor.php +++ b/components/XML/XMLProcessor.php @@ -878,7 +878,7 @@ protected function __construct( $xml, $document_namespaces=[], $use_the_static_c '6.4.0' ); } - $this->xml = $xml; + $this->xml = $xml ?? ''; $this->document_namespaces = array_merge( $document_namespaces, // These initial namespaces cannot be overridden. diff --git a/phar-blueprints.json b/phar-blueprints.json index ba9fbd99..4a38e9ff 100644 --- a/phar-blueprints.json +++ b/phar-blueprints.json @@ -27,5 +27,8 @@ ], "directories": [ "vendor/composer" + ], + "files": [ + "dist/php-toolkit.phar" ] }