Skip to content

Commit bb46a41

Browse files
authored
Support separate web root and WordPress core directory (#217)
## Summary On WP Cloud sites, the web root and WordPress core directory are separate paths: - Web root: `/srv/htdocs` (contains `wp-content/`) - WP root: `/srv/htdocs/__wp__/` (contains `wp-load.php`, `wp-includes/`, `wp-admin/`) The blueprint runner previously assumed these were always the same directory. All subprocess scripts used `DOCROOT` to find both `wp-content/` and `wp-load.php`, which breaks on split layouts. This PR introduces a `WP_CORE_DIR` environment variable that points to where WordPress core files live, independent of `DOCROOT` (the web root). On standard installs they're identical; on WP Cloud they differ. The `ExistingSiteResolver` auto-detects the core directory by scanning immediate subdirectories for `wp-load.php`, so existing blueprints work without changes. A `--wp-core-path` CLI flag is also available for explicit configuration. ## Test plan - [ ] New unit tests for `detect_wordpress_core_dir()` (standard layout, `__wp__/` subdirectory, custom subdirectory, no WP found, deeply nested, nonexistent path) - [ ] New unit tests for `RunnerConfiguration` (default fallback, explicit override, reset to null) - [ ] Verify existing CI tests still pass (the change is backwards-compatible since `WP_CORE_DIR` defaults to `DOCROOT`) - [ ] Test on a WP Cloud site with split web/core roots
1 parent 355af4f commit bb46a41

29 files changed

Lines changed: 370 additions & 70 deletions

components/Blueprints/SiteResolver/class-existingsiteresolver.php

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,26 @@ public static function resolve( Runtime $runtime, Tracker $progress, ?VersionCon
2424

2525
// 1. Verify it's a valid WordPress installation.
2626
$progress['verify_installation']->setCaption( 'Verifying WordPress installation' );
27+
28+
// Auto-detect the WordPress core directory. Some hosting setups place
29+
// the WordPress core files (wp-load.php, wp-admin, wp-includes) in a
30+
// subdirectory while wp-content stays in the web root.
2731
if ( ! $target_fs->exists( 'wp-load.php' ) ) {
28-
throw new BlueprintExecutionException(
29-
'The target site does not appear to be a valid WordPress installation (wp-load.php not found)'
30-
);
32+
$detected_core_dir = self::detect_wordpress_core_dir( $config->get_target_site_root() );
33+
if ( null !== $detected_core_dir ) {
34+
$config->set_wordpress_core_dir( $detected_core_dir );
35+
} else {
36+
throw new BlueprintExecutionException(
37+
'The target site does not appear to be a valid WordPress installation (wp-load.php not found)'
38+
);
39+
}
3140
}
3241

3342
// Additional check to ensure we can actually load WordPress.
3443
try {
3544
$result = $runtime->eval_php_code_in_subprocess(
3645
'<?php
37-
require_once(getenv("DOCROOT") . "/wp-load.php");
46+
require_once(getenv("WP_CORE_DIR") . "/wp-load.php");
3847
$is_installed = function_exists("is_blog_installed") && is_blog_installed() ? "true" : "false";
3948
append_output("WordPress is installed: " . $is_installed);
4049
'
@@ -61,7 +70,7 @@ public static function resolve( Runtime $runtime, Tracker $progress, ?VersionCon
6170
trim(
6271
$runtime->eval_php_code_in_subprocess(
6372
'<?php
64-
require_once(getenv("DOCROOT") . "/wp-includes/version.php");
73+
require_once(getenv("WP_CORE_DIR") . "/wp-includes/version.php");
6574
append_output( $wp_version );
6675
'
6776
)->output_file_content
@@ -92,7 +101,7 @@ public static function resolve( Runtime $runtime, Tracker $progress, ?VersionCon
92101
if ( 'sqlite' === $required_engine ) {
93102
$sqlite_active = $runtime->eval_php_code_in_subprocess(
94103
'<?php
95-
require_once(getenv("DOCROOT") . "/wp-load.php");
104+
require_once(getenv("WP_CORE_DIR") . "/wp-load.php");
96105
97106
// Check if SQLite integration is active
98107
$sqlite_plugin = WP_CONTENT_DIR . "/plugins/sqlite-database-integration/load.php";
@@ -113,7 +122,7 @@ public static function resolve( Runtime $runtime, Tracker $progress, ?VersionCon
113122
// For MySQL, verify it's not using SQLite.
114123
$using_mysql = $runtime->eval_php_code_in_subprocess(
115124
'<?php
116-
require_once(getenv("DOCROOT") . "/wp-load.php");
125+
require_once(getenv("WP_CORE_DIR") . "/wp-load.php");
117126
118127
// Check if SQLite integration is NOT active
119128
$active_plugins = get_option("active_plugins");
@@ -139,4 +148,52 @@ public static function resolve( Runtime $runtime, Tracker $progress, ?VersionCon
139148
$progress['verify_database']->finish();
140149
$progress->finish();
141150
}
151+
152+
/**
153+
* Scans the web root for a WordPress core directory. Some hosting
154+
* setups place the core files in a subdirectory while wp-content/
155+
* stays in the web root.
156+
*
157+
* @param string $web_root Absolute path to the web root.
158+
*
159+
* @return string|null Absolute path to the WordPress core directory, or
160+
* null when wp-load.php cannot be found anywhere.
161+
*/
162+
public static function detect_wordpress_core_dir( string $web_root ): ?string {
163+
// Standard layout: wp-load.php is in the web root itself.
164+
if ( file_exists( $web_root . '/wp-load.php' ) ) {
165+
// If wp-load.php is a symlink pointing into a subdirectory,
166+
// resolve it to find the real core directory. Some hosting
167+
// setups (e.g. WP Cloud) place a symlink at the web root
168+
// while the actual core files live in a subdirectory like
169+
// __wp__/.
170+
if ( is_link( $web_root . '/wp-load.php' ) ) {
171+
$real_path = realpath( $web_root . '/wp-load.php' );
172+
if ( false !== $real_path ) {
173+
return dirname( $real_path );
174+
}
175+
}
176+
return $web_root;
177+
}
178+
179+
// Scan immediate subdirectories for wp-load.php. This covers the
180+
// Check immediate subdirectories for any single-level split layout.
181+
$entries = @scandir( $web_root );
182+
if ( false === $entries ) {
183+
return null;
184+
}
185+
186+
foreach ( $entries as $entry ) {
187+
if ( '.' === $entry || '..' === $entry ) {
188+
continue;
189+
}
190+
191+
$candidate = $web_root . '/' . $entry;
192+
if ( is_dir( $candidate ) && file_exists( $candidate . '/wp-load.php' ) ) {
193+
return $candidate;
194+
}
195+
}
196+
197+
return null;
198+
}
142199
}

components/Blueprints/SiteResolver/class-newsiteresolver.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ private static function is_wordpress_installed( Runtime $runtime, Tracker $progr
153153
$install_check = $runtime->eval_php_code_in_subprocess(
154154
<<<'PHP'
155155
<?php
156-
$wp_load = getenv('DOCROOT') . '/wp-load.php';
156+
$wp_load = getenv('WP_CORE_DIR') . '/wp-load.php';
157157
if (!file_exists($wp_load)) {
158158
append_output('0');
159159
exit;
@@ -164,7 +164,8 @@ private static function is_wordpress_installed( Runtime $runtime, Tracker $progr
164164
PHP
165165
,
166166
array(
167-
'DOCROOT' => $runtime->get_configuration()->get_target_site_root(),
167+
'DOCROOT' => $runtime->get_configuration()->get_target_site_root(),
168+
'WP_CORE_DIR' => $runtime->get_configuration()->get_wordpress_core_dir(),
168169
),
169170
null,
170171
5

components/Blueprints/Steps/class-activatepluginstep.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ class ActivatePluginStep implements StepInterface {
1515
<?php
1616
1717
define( 'WP_ADMIN', true );
18-
require_once getenv( 'DOCROOT' ) . '/wp-load.php';
19-
require_once getenv( 'DOCROOT' ) . '/wp-admin/includes/plugin.php';
18+
require_once getenv( 'WP_CORE_DIR' ) . '/wp-load.php';
19+
require_once getenv( 'WP_CORE_DIR' ) . '/wp-admin/includes/plugin.php';
2020
2121
// Set current user to admin
2222
set_current_user( get_users( array( 'role' => 'Administrator' ) )[0] );

components/Blueprints/Steps/class-activatethemestep.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class ActivateThemeStep implements StepInterface {
1616
<?php
1717
1818
define( 'WP_ADMIN', true );
19-
require_once getenv( 'DOCROOT' ) . '/wp-load.php';
19+
require_once getenv( 'WP_CORE_DIR' ) . '/wp-load.php';
2020
2121
// Set current user to admin
2222
set_current_user( get_users( array( 'role' => 'Administrator' ) )[0] );

components/Blueprints/Steps/class-defineconstantsstep.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ function find_first_token_index( $tokens, $type, $search = null ) {
430430
return null;
431431
}
432432
433-
$wp_config_path = getenv( "DOCROOT" ) . "/wp-config.php";
433+
$wp_config_path = getenv( "WP_CORE_DIR" ) . "/wp-config.php";
434434
435435
if ( ! file_exists( $wp_config_path ) ) {
436436
error_log( "Blueprint Error: wp-config.php file not found at " . $wp_config_path );

components/Blueprints/Steps/class-enablemultisitestep.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ public function run( Runtime $runtime, Tracker $tracker ) {
2222
* See: https://github.com/wp-cli/core-command/blob/f157fb37dae1d13fe7318452f932917161e83e53/src/Core_Command.php#L505
2323
*/
2424
25-
require_once getenv( 'DOCROOT' ) . '/wp-load.php';
26-
require_once getenv( 'DOCROOT' ) . '/wp-admin/includes/upgrade.php';
25+
require_once getenv( 'WP_CORE_DIR' ) . '/wp-load.php';
26+
require_once getenv( 'WP_CORE_DIR' ) . '/wp-admin/includes/upgrade.php';
2727
2828
// need to register the multisite tables manually for some reason
2929
foreach ( $wpdb->tables( 'ms_global' ) as $table => $prefixed_table ) {

components/Blueprints/Steps/class-importcontentstep.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ private function importPosts( Runtime $runtime, $post ): void {
151151
$runtime->eval_php_code_in_subprocess(
152152
<<<'PHP'
153153
<?php
154-
require_once getenv('DOCROOT') . '/wp-load.php';
154+
require_once getenv('WP_CORE_DIR') . '/wp-load.php';
155155
foreach (json_decode(getenv('POSTS'), true) as $post) {
156156
$result = wp_insert_post(wp_slash($post));
157157
if (is_wp_error($result)) {

components/Blueprints/Steps/class-importmediastep.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ public function run( Runtime $runtime, Tracker $progress ) {
6666
$fs = $runtime->get_target_filesystem();
6767
$wp_upload_dir = $runtime->eval_php_code_in_subprocess(
6868
'<?php
69-
require_once(getenv("DOCROOT") . "/wp-load.php");
69+
require_once(getenv("WP_CORE_DIR") . "/wp-load.php");
7070
$upload_dir = wp_upload_dir();
7171
append_output( json_encode($upload_dir) );
7272
'
@@ -130,8 +130,8 @@ function ( $media ) {
130130
$attachment_id = $runtime->eval_php_code_in_subprocess(
131131
<<<'CODE'
132132
<?php
133-
require_once(getenv("DOCROOT") . "/wp-load.php");
134-
require_once(getenv("DOCROOT") . "/wp-admin/includes/image.php");
133+
require_once(getenv("WP_CORE_DIR") . "/wp-load.php");
134+
require_once(getenv("WP_CORE_DIR") . "/wp-admin/includes/image.php");
135135
136136
$file_path = getenv("MEDIA_FILE_PATH");
137137
$attachment_meta = json_decode(getenv("ATTACHMENT_META"), true);

components/Blueprints/Steps/class-importthemestartercontentstep.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ function importThemeStarterContent_plugins_loaded() {
6565
'accepted_args' => 0,
6666
);
6767
68-
require getenv( "DOCROOT" ) . '/wp-load.php';
68+
require getenv( "WP_CORE_DIR" ) . '/wp-load.php';
6969
7070
// Return early if there's no starter content.
7171
if ( ! get_theme_starter_content() ) {

components/Blueprints/Steps/class-installpluginstep.php

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,13 @@ function ( $temp_dir ) use ( $runtime, $tracker, $plugin_data ) {
105105
<<<'PHP'
106106
<?php
107107
108-
require_once getenv( 'DOCROOT' ) . '/wp-load.php';
108+
require_once getenv( 'WP_CORE_DIR' ) . '/wp-load.php';
109109
110110
define( 'WP_ADMIN', true );
111111
112112
// Define a dummy skin for the upgrader.
113113
if ( ! class_exists( '\WP_Upgrader_Skin', false ) ) {
114-
require_once getenv( 'DOCROOT' ) . '/wp-admin/includes/class-wp-upgrader.php';
114+
require_once getenv( 'WP_CORE_DIR' ) . '/wp-admin/includes/class-wp-upgrader.php';
115115
116116
class Blueprint_WP_Upgrader_Skin extends WP_Upgrader_Skin {
117117
public $destination;
@@ -186,11 +186,11 @@ public function after( $title = '' ) {
186186
}
187187
}
188188
189-
require_once getenv( 'DOCROOT' ) . '/wp-load.php';
190-
require_once getenv( 'DOCROOT' ) . '/wp-admin/includes/plugin.php';
191-
require_once getenv( 'DOCROOT' ) . '/wp-admin/includes/file.php';
192-
require_once getenv( 'DOCROOT' ) . '/wp-admin/includes/plugin-install.php';
193-
require_once getenv( 'DOCROOT' ) . '/wp-admin/includes/class-wp-upgrader.php';
189+
require_once getenv( 'WP_CORE_DIR' ) . '/wp-load.php';
190+
require_once getenv( 'WP_CORE_DIR' ) . '/wp-admin/includes/plugin.php';
191+
require_once getenv( 'WP_CORE_DIR' ) . '/wp-admin/includes/file.php';
192+
require_once getenv( 'WP_CORE_DIR' ) . '/wp-admin/includes/plugin-install.php';
193+
require_once getenv( 'WP_CORE_DIR' ) . '/wp-admin/includes/class-wp-upgrader.php';
194194
195195
// Ensure filesystem access is properly set up
196196
WP_Filesystem();

0 commit comments

Comments
 (0)