diff --git a/components/Blueprints/SiteResolver/class-existingsiteresolver.php b/components/Blueprints/SiteResolver/class-existingsiteresolver.php index f42d6d0b..738f8515 100644 --- a/components/Blueprints/SiteResolver/class-existingsiteresolver.php +++ b/components/Blueprints/SiteResolver/class-existingsiteresolver.php @@ -24,17 +24,26 @@ public static function resolve( Runtime $runtime, Tracker $progress, ?VersionCon // 1. Verify it's a valid WordPress installation. $progress['verify_installation']->setCaption( 'Verifying WordPress installation' ); + + // Auto-detect the WordPress core directory. Some hosting setups place + // the WordPress core files (wp-load.php, wp-admin, wp-includes) in a + // subdirectory while wp-content stays in the web root. if ( ! $target_fs->exists( 'wp-load.php' ) ) { - throw new BlueprintExecutionException( - 'The target site does not appear to be a valid WordPress installation (wp-load.php not found)' - ); + $detected_core_dir = self::detect_wordpress_core_dir( $config->get_target_site_root() ); + if ( null !== $detected_core_dir ) { + $config->set_wordpress_core_dir( $detected_core_dir ); + } else { + throw new BlueprintExecutionException( + 'The target site does not appear to be a valid WordPress installation (wp-load.php not found)' + ); + } } // Additional check to ensure we can actually load WordPress. try { $result = $runtime->eval_php_code_in_subprocess( 'eval_php_code_in_subprocess( 'output_file_content @@ -92,7 +101,7 @@ public static function resolve( Runtime $runtime, Tracker $progress, ?VersionCon if ( 'sqlite' === $required_engine ) { $sqlite_active = $runtime->eval_php_code_in_subprocess( 'eval_php_code_in_subprocess( 'finish(); $progress->finish(); } + + /** + * Scans the web root for a WordPress core directory. Some hosting + * setups place the core files in a subdirectory while wp-content/ + * stays in the web root. + * + * @param string $web_root Absolute path to the web root. + * + * @return string|null Absolute path to the WordPress core directory, or + * null when wp-load.php cannot be found anywhere. + */ + public static function detect_wordpress_core_dir( string $web_root ): ?string { + // Standard layout: wp-load.php is in the web root itself. + if ( file_exists( $web_root . '/wp-load.php' ) ) { + return $web_root; + } + + // Scan immediate subdirectories for wp-load.php. This covers the + // Check immediate subdirectories for any single-level split layout. + $entries = @scandir( $web_root ); + if ( false === $entries ) { + return null; + } + + foreach ( $entries as $entry ) { + if ( '.' === $entry || '..' === $entry ) { + continue; + } + + $candidate = $web_root . '/' . $entry; + if ( is_dir( $candidate ) && file_exists( $candidate . '/wp-load.php' ) ) { + return $candidate; + } + } + + return null; + } } diff --git a/components/Blueprints/SiteResolver/class-newsiteresolver.php b/components/Blueprints/SiteResolver/class-newsiteresolver.php index 5aa4ef81..4ace812a 100644 --- a/components/Blueprints/SiteResolver/class-newsiteresolver.php +++ b/components/Blueprints/SiteResolver/class-newsiteresolver.php @@ -153,7 +153,7 @@ private static function is_wordpress_installed( Runtime $runtime, Tracker $progr $install_check = $runtime->eval_php_code_in_subprocess( <<<'PHP' $runtime->get_configuration()->get_target_site_root(), + 'DOCROOT' => $runtime->get_configuration()->get_target_site_root(), + 'WP_CORE_DIR' => $runtime->get_configuration()->get_wordpress_core_dir(), ), null, 5 diff --git a/components/Blueprints/Steps/class-activatepluginstep.php b/components/Blueprints/Steps/class-activatepluginstep.php index 7af6362c..9de21314 100644 --- a/components/Blueprints/Steps/class-activatepluginstep.php +++ b/components/Blueprints/Steps/class-activatepluginstep.php @@ -15,8 +15,8 @@ class ActivatePluginStep implements StepInterface { 'Administrator' ) )[0] ); diff --git a/components/Blueprints/Steps/class-activatethemestep.php b/components/Blueprints/Steps/class-activatethemestep.php index a6e5bc61..147709cc 100644 --- a/components/Blueprints/Steps/class-activatethemestep.php +++ b/components/Blueprints/Steps/class-activatethemestep.php @@ -16,7 +16,7 @@ class ActivateThemeStep implements StepInterface { 'Administrator' ) )[0] ); diff --git a/components/Blueprints/Steps/class-defineconstantsstep.php b/components/Blueprints/Steps/class-defineconstantsstep.php index 07a672e9..6f92199b 100644 --- a/components/Blueprints/Steps/class-defineconstantsstep.php +++ b/components/Blueprints/Steps/class-defineconstantsstep.php @@ -430,7 +430,7 @@ function find_first_token_index( $tokens, $type, $search = null ) { return null; } -$wp_config_path = getenv( "DOCROOT" ) . "/wp-config.php"; +$wp_config_path = getenv( "WP_CORE_DIR" ) . "/wp-config.php"; if ( ! file_exists( $wp_config_path ) ) { error_log( "Blueprint Error: wp-config.php file not found at " . $wp_config_path ); diff --git a/components/Blueprints/Steps/class-enablemultisitestep.php b/components/Blueprints/Steps/class-enablemultisitestep.php index 14e6ded2..42aab631 100644 --- a/components/Blueprints/Steps/class-enablemultisitestep.php +++ b/components/Blueprints/Steps/class-enablemultisitestep.php @@ -22,8 +22,8 @@ public function run( Runtime $runtime, Tracker $tracker ) { * See: https://github.com/wp-cli/core-command/blob/f157fb37dae1d13fe7318452f932917161e83e53/src/Core_Command.php#L505 */ -require_once getenv( 'DOCROOT' ) . '/wp-load.php'; -require_once getenv( 'DOCROOT' ) . '/wp-admin/includes/upgrade.php'; +require_once getenv( 'WP_CORE_DIR' ) . '/wp-load.php'; +require_once getenv( 'WP_CORE_DIR' ) . '/wp-admin/includes/upgrade.php'; // need to register the multisite tables manually for some reason foreach ( $wpdb->tables( 'ms_global' ) as $table => $prefixed_table ) { diff --git a/components/Blueprints/Steps/class-importcontentstep.php b/components/Blueprints/Steps/class-importcontentstep.php index 6c81d8c2..b0908856 100644 --- a/components/Blueprints/Steps/class-importcontentstep.php +++ b/components/Blueprints/Steps/class-importcontentstep.php @@ -151,7 +151,7 @@ private function importPosts( Runtime $runtime, $post ): void { $runtime->eval_php_code_in_subprocess( <<<'PHP' get_target_filesystem(); $wp_upload_dir = $runtime->eval_php_code_in_subprocess( 'eval_php_code_in_subprocess( <<<'CODE' 0, ); -require getenv( "DOCROOT" ) . '/wp-load.php'; +require getenv( "WP_CORE_DIR" ) . '/wp-load.php'; // Return early if there's no starter content. if ( ! get_theme_starter_content() ) { diff --git a/components/Blueprints/Steps/class-installpluginstep.php b/components/Blueprints/Steps/class-installpluginstep.php index 113ec612..6f9113c5 100644 --- a/components/Blueprints/Steps/class-installpluginstep.php +++ b/components/Blueprints/Steps/class-installpluginstep.php @@ -105,13 +105,13 @@ function ( $temp_dir ) use ( $runtime, $tracker, $plugin_data ) { <<<'PHP' eval_php_code_in_subprocess( <<<'CODE' eval_php_code_in_subprocess( 'output_file_content @@ -67,8 +67,8 @@ public function run( Runtime $runtime, Tracker $progress ) { $plugins_data = json_decode( $runtime->eval_php_code_in_subprocess( "eval_php_code_in_subprocess( "setCaption( 'Setting site options' ); $runtime->eval_php_code_in_subprocess( ' $value) { update_option($name, $value); diff --git a/components/Blueprints/Steps/scripts/import-content.php b/components/Blueprints/Steps/scripts/import-content.php index b98095fe..4bfeb25e 100644 --- a/components/Blueprints/Steps/scripts/import-content.php +++ b/components/Blueprints/Steps/scripts/import-content.php @@ -22,7 +22,7 @@ use function WordPress\Filesystem\wp_join_unix_paths; -require_once getenv( 'DOCROOT' ) . '/wp-load.php'; +require_once getenv( 'WP_CORE_DIR' ) . '/wp-load.php'; require_once getenv( 'DOCROOT' ) . '/php-toolkit.phar'; // Progress reporting interfaces and implementations. diff --git a/components/Blueprints/Tests/Unit/RunnerConfigurationTest.php b/components/Blueprints/Tests/Unit/RunnerConfigurationTest.php new file mode 100644 index 00000000..3c50cd1e --- /dev/null +++ b/components/Blueprints/Tests/Unit/RunnerConfigurationTest.php @@ -0,0 +1,45 @@ +set_target_site_root( '/srv/htdocs' ); + + $this->assertSame( '/srv/htdocs', $config->get_wordpress_core_dir() ); + } + + /** + * When a WordPress core dir is explicitly set, it takes precedence + * over the target site root. + */ + public function test_wordpress_core_dir_can_be_set_independently() { + $config = new RunnerConfiguration(); + $config->set_target_site_root( '/srv/htdocs' ); + $config->set_wordpress_core_dir( '/srv/htdocs/wp' ); + + $this->assertSame( '/srv/htdocs', $config->get_target_site_root() ); + $this->assertSame( '/srv/htdocs/wp', $config->get_wordpress_core_dir() ); + } + + /** + * Setting WordPress core dir to null resets to the default behavior + * (falling back to the site root). + */ + public function test_wordpress_core_dir_reset_to_null_falls_back_to_site_root() { + $config = new RunnerConfiguration(); + $config->set_target_site_root( '/srv/htdocs' ); + $config->set_wordpress_core_dir( '/srv/htdocs/wp' ); + $config->set_wordpress_core_dir( null ); + + $this->assertSame( '/srv/htdocs', $config->get_wordpress_core_dir() ); + } +} diff --git a/components/Blueprints/Tests/Unit/SiteResolver/DetectWordPressCoreDirTest.php b/components/Blueprints/Tests/Unit/SiteResolver/DetectWordPressCoreDirTest.php new file mode 100644 index 00000000..01a69ecb --- /dev/null +++ b/components/Blueprints/Tests/Unit/SiteResolver/DetectWordPressCoreDirTest.php @@ -0,0 +1,126 @@ +temp_dir = wp_unix_sys_get_temp_dir() . '/wp_core_detect_' . uniqid(); + mkdir( $this->temp_dir, 0755, true ); + } + + protected function tearDown(): void { + $this->remove_directory( $this->temp_dir ); + } + + /** + * Standard layout: wp-load.php is in the web root. + */ + public function test_detects_standard_layout() { + touch( $this->temp_dir . '/wp-load.php' ); + + $result = ExistingSiteResolver::detect_wordpress_core_dir( $this->temp_dir ); + + $this->assertSame( $this->temp_dir, $result ); + } + + /** + * Split layout: wp-load.php lives in a subdirectory of the web root. + */ + public function test_detects_split_layout_with_subdirectory() { + // Web root has wp-content but not wp-load.php. + mkdir( $this->temp_dir . '/wp-content', 0755, true ); + + // WordPress core is in a subdirectory. + $wp_core = $this->temp_dir . '/wp'; + mkdir( $wp_core, 0755, true ); + touch( $wp_core . '/wp-load.php' ); + + $result = ExistingSiteResolver::detect_wordpress_core_dir( $this->temp_dir ); + + $this->assertSame( $wp_core, $result ); + } + + /** + * Custom subdirectory layout: wp-load.php in an arbitrary subdirectory. + */ + public function test_detects_custom_subdirectory_layout() { + $wp_core = $this->temp_dir . '/core'; + mkdir( $wp_core, 0755, true ); + touch( $wp_core . '/wp-load.php' ); + + $result = ExistingSiteResolver::detect_wordpress_core_dir( $this->temp_dir ); + + $this->assertSame( $wp_core, $result ); + } + + /** + * No WordPress installation: returns null when wp-load.php is not + * found anywhere. + */ + public function test_returns_null_when_no_wordpress_found() { + // Empty directory, no wp-load.php anywhere. + $result = ExistingSiteResolver::detect_wordpress_core_dir( $this->temp_dir ); + + $this->assertNull( $result ); + } + + /** + * Deeply nested wp-load.php should NOT be detected. Only the + * web root and its immediate subdirectories are searched. + */ + public function test_does_not_detect_deeply_nested_wp_load() { + $deep = $this->temp_dir . '/a/b/c'; + mkdir( $deep, 0755, true ); + touch( $deep . '/wp-load.php' ); + + $result = ExistingSiteResolver::detect_wordpress_core_dir( $this->temp_dir ); + + $this->assertNull( $result ); + } + + /** + * Non-existent directory: returns null gracefully. + */ + public function test_returns_null_for_nonexistent_directory() { + $result = ExistingSiteResolver::detect_wordpress_core_dir( '/nonexistent/path/' . uniqid() ); + + $this->assertNull( $result ); + } + + private function remove_directory( string $dir ): void { + if ( ! is_dir( $dir ) ) { + return; + } + $entries = scandir( $dir ); + foreach ( $entries as $entry ) { + if ( '.' === $entry || '..' === $entry ) { + continue; + } + $path = $dir . '/' . $entry; + if ( is_dir( $path ) ) { + $this->remove_directory( $path ); + } else { + unlink( $path ); + } + } + rmdir( $dir ); + } +} diff --git a/components/Blueprints/Tests/Unit/Steps/DefineConstantsStepTest.php b/components/Blueprints/Tests/Unit/Steps/DefineConstantsStepTest.php index bee71d3e..b61d36ce 100644 --- a/components/Blueprints/Tests/Unit/Steps/DefineConstantsStepTest.php +++ b/components/Blueprints/Tests/Unit/Steps/DefineConstantsStepTest.php @@ -245,7 +245,7 @@ private function assertWordPressConstants( array $expected_constants ) { <<<'PHP' runtime->eval_php_code_in_subprocess( <<<'PHP' runtime->eval_php_code_in_subprocess( <<<'PHP' runtime->eval_php_code_in_subprocess( <<<'PHP' runtime->eval_php_code_in_subprocess( <<<'PHP' runtime->eval_php_code_in_subprocess( <<<'PHP' runtime->eval_php_code_in_subprocess( <<<'PHP' runtime->eval_php_code_in_subprocess( <<<'PHP' runtime->eval_php_code_in_subprocess( <<<'PHP' runtime->eval_php_code_in_subprocess( <<<'PHP' runtime->eval_php_code_in_subprocess( <<<'PHP' runtime->eval_php_code_in_subprocess( <<<'PHP' runtime->eval_php_code_in_subprocess( <<<'PHP' 'script.php', 'content' => <<runtime->eval_php_code_in_subprocess( <<<'PHP' runtime->eval_php_code_in_subprocess( <<<'PHP' get_var("SHOW TABLES LIKE '$table_name'"); @@ -57,7 +57,7 @@ public function testRunSQLQueryWithInserts() { $result = $this->runtime->eval_php_code_in_subprocess( <<<'PHP' get_var("SELECT COUNT(*) FROM test_table"); $rows = $wpdb->get_results("SELECT * FROM test_table ORDER BY id", ARRAY_A); @@ -94,7 +94,7 @@ public function testRunSQLQueryModifyingWordPressOptions() { $option_value = $this->runtime->eval_php_code_in_subprocess( <<<'PHP' runtime->eval_php_code_in_subprocess( <<<'PHP' get_var("SELECT value FROM test_table_1 LIMIT 1"); @@ -157,7 +157,7 @@ public function testHandleSQLErrors() { $table_exists = $this->runtime->eval_php_code_in_subprocess( <<<'PHP' get_var("SHOW TABLES LIKE '$table_name'"); diff --git a/components/Blueprints/Tests/Unit/Steps/SetSiteOptionsStepTest.php b/components/Blueprints/Tests/Unit/Steps/SetSiteOptionsStepTest.php index 8693123a..f0affc68 100644 --- a/components/Blueprints/Tests/Unit/Steps/SetSiteOptionsStepTest.php +++ b/components/Blueprints/Tests/Unit/Steps/SetSiteOptionsStepTest.php @@ -16,7 +16,7 @@ private function assertWordPressOptions( array $expected_options ) { $result = $this->runtime->eval_php_code_in_subprocess( <<<'PHP' runtime->eval_php_code_in_subprocess( <<<'PHP' 'script.php', 'content' => <<<'PHP' query('DELETE FROM wp_posts WHERE id > 0'); $GLOBALS['@pdo']->query("UPDATE SQLITE_SEQUENCE SET SEQ=0 WHERE NAME='wp_posts'"); @@ -448,7 +448,7 @@ public function upgrade( array $validated_v1_blueprint ): array { 'filename' => 'script.php', 'content' => <<<'PHP' $value) { update_user_meta(getenv("USER_ID"), $name, $value); diff --git a/components/Blueprints/bin/blueprint.php b/components/Blueprints/bin/blueprint.php index 7a6c43a5..05abf0fa 100644 --- a/components/Blueprints/bin/blueprint.php +++ b/components/Blueprints/bin/blueprint.php @@ -269,6 +269,7 @@ function createProgressReporter(): ProgressReporter { array( 'site-url' => array( 'u', true, null, 'Public site URL (https://example.com)' ), 'site-path' => array( null, true, null, 'Target directory with WordPress install context)' ), + 'wp-core-path' => array( null, true, null, 'WordPress core directory (if different from site-path)' ), 'execution-context' => array( 'x', true, null, 'Source directory with Blueprint context files' ), 'mode' => array( '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' => array( 'd', true, 'mysql', 'Database engine (mysql|sqlite)' ), @@ -475,6 +476,15 @@ function cliArgsToRunnerConfiguration( array $positional_args, array $options ): $config->set_target_site_root( $absolute_target_site_root ); $config->set_target_site_url( $options['site-url'] ); + // Set WordPress core directory if explicitly provided. + if ( ! empty( $options['wp-core-path'] ) ) { + $absolute_wp_core_path = realpath( $options['wp-core-path'] ); + if ( false === $absolute_wp_core_path || ! is_dir( $absolute_wp_core_path ) ) { + throw new InvalidArgumentException( "The --wp-core-path path does not exist: {$options['wp-core-path']}" ); + } + $config->set_wordpress_core_dir( $absolute_wp_core_path ); + } + // Set database engine. if ( ! empty( $options['db-engine'] ) ) { $config->set_database_engine( $options['db-engine'] ); diff --git a/components/Blueprints/class-runner.php b/components/Blueprints/class-runner.php index 5678c849..1daa3495 100644 --- a/components/Blueprints/class-runner.php +++ b/components/Blueprints/class-runner.php @@ -839,7 +839,7 @@ private function create_step_object( string $step_type, array $data ) { } $code = 'root_dir; } + /** + * Sets the WordPress core directory path. This is where wp-load.php, + * wp-admin/, and wp-includes/ live. When null, it defaults to the + * target site root. + */ + public function set_wordpress_core_dir( ?string $d ): self { + $this->wordpress_core_dir = $d; + + return $this; + } + + /** + * Gets the WordPress core directory path. Falls back to the target + * site root when not explicitly set. + */ + public function get_wordpress_core_dir(): string { + return $this->wordpress_core_dir ?? $this->root_dir; + } + public function set_target_site_url( string $u ): self { $this->site_url = $u; diff --git a/components/Blueprints/class-runtime.php b/components/Blueprints/class-runtime.php index 8438661b..712c54d5 100644 --- a/components/Blueprints/class-runtime.php +++ b/components/Blueprints/class-runtime.php @@ -177,7 +177,10 @@ public function create_temporary_file( ?string $suffix = null ): string { * * * append_output( $output ): A function that appends a given string to the output file. Useful for * separating the returned structured data from PHP warnings and echos. - * * DOCROOT environment variable: The path to the WordPress root directory. + * * DOCROOT environment variable: The path to the web root directory (document root). + * * WP_CORE_DIR environment variable: The path to the WordPress core directory (where wp-load.php lives). + * On standard installs this equals DOCROOT. Some hosts place + * the core in a subdirectory separate from the web root. * * OUTPUT_FILE environment variable: The path to a file where the output of the code will be appended. * * @TODO: Useful error messages on process failure. Right now we get this mouthful error message: @@ -264,6 +267,7 @@ function ( $script_path ) use ( $code, $env, $input, $timeout ) { array_merge( array( 'DOCROOT' => $this->configuration->get_target_site_root(), + 'WP_CORE_DIR' => $this->configuration->get_wordpress_core_dir(), 'OUTPUT_FILE' => $output_path, ), $env ?? array()