From 7c03b93ec770fbd27b10b2c2f66775be2b5d920c Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 16 Jul 2026 15:54:36 +0530 Subject: [PATCH 1/8] fix(clone): detect empty destination host in site_exists `ee site clone`/`ee site sync` aborted with "Unable to get site list" whenever the destination host had zero existing EE sites. On an empty host `ee site list` calls \EE::error('No sites found!'), which writes to stderr and exits 1, but site_exists() inspected stdout for the sentinel, so the special-case never matched and the generic error path threw. Match the sentinel against stderr . stdout (covering both the local separate-streams case and the ssh -t merged-output case) and use strpos to tolerate trailing whitespace/debug noise. Also guard validate_parent_site_present_on_host() against a null json_decode on an empty host, which previously triggered a PHP warning from foreach(null). Refs rtCamp/EasyDash#4135 --- src/clone/Cloner.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/clone/Cloner.php b/src/clone/Cloner.php index 49980fd0..521697aa 100644 --- a/src/clone/Cloner.php +++ b/src/clone/Cloner.php @@ -84,6 +84,11 @@ public function validate_parent_site_present_on_host( string $site ): void { $list_result = $this->execute( 'ee site list --format=json' ); $list_result = json_decode( $list_result->stdout, true ); + // Empty host: `ee site list` returns no JSON ("No sites found!" on stderr), so there is no parent to match. + if ( ! is_array( $list_result ) ) { + $list_result = []; + } + foreach ( $list_result as $site_details ) { $parent_site = $site_details['site']; $substr_match = strpos( $site, $parent_site ); @@ -290,14 +295,13 @@ public function create_site( Site $source_site, $assoc_args ): EE\ProcessRun { public function site_exists(): bool { $site_list = $this->execute( 'ee site list --format=json --no-color' ); - if ( 1 === $site_list->return_code ) { - $error = trim ( preg_replace( '#\\x1b[[][^A-Za-z]*[A-Za-z]#', '', $site_list->stdout ) ); - if ( 'Error: No sites found!' === $error ) { + if ( 0 !== $site_list->return_code ) { + // "No sites found!" is emitted on stderr (merged into stdout under `ssh -t`). + $output = trim( preg_replace( '#\\x1b[[][^A-Za-z]*[A-Za-z]#', '', $site_list->stderr . $site_list->stdout ) ); + if ( false !== strpos( $output, 'Error: No sites found!' ) ) { return false; } - } - if ( 0 !== $site_list->return_code ) { throw new \Exception( 'Unable to get site list on ' . $this->user . '@' . $this->host ); } From 41aee5692d5eec2ef3f901e47eefb99f5f4d3f56 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 16 Jul 2026 16:13:07 +0530 Subject: [PATCH 2/8] fix(clone): harden empty-host detection after review - Extract is_no_sites_error() so site_exists() and validate_parent_site_present_on_host() share one sentinel check. - Narrow the gate back to exit code 1 (the code \EE::error always exits with) so an unrelated non-zero failure can no longer be mistaken for 'no sites' via a substring match. - validate_parent_site_present_on_host() now distinguishes an empty host from a genuine 'ee site list' failure, surfacing the latter as 'Unable to get site list' instead of masking it as 'parent not found'. --- src/clone/Cloner.php | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/clone/Cloner.php b/src/clone/Cloner.php index 521697aa..86d2b4a3 100644 --- a/src/clone/Cloner.php +++ b/src/clone/Cloner.php @@ -81,11 +81,14 @@ public function validate_ee_version(): void { } public function validate_parent_site_present_on_host( string $site ): void { - $list_result = $this->execute( 'ee site list --format=json' ); - $list_result = json_decode( $list_result->stdout, true ); + $list_run = $this->execute( 'ee site list --format=json' ); + $list_result = json_decode( $list_run->stdout, true ); - // Empty host: `ee site list` returns no JSON ("No sites found!" on stderr), so there is no parent to match. if ( ! is_array( $list_result ) ) { + // An empty host has no parent to match; any other failure must surface, not be masked as "parent not found". + if ( 0 !== $list_run->return_code && ! $this->is_no_sites_error( $list_run ) ) { + throw new \Exception( 'Unable to get site list on ' . $this->user . '@' . $this->host ); + } $list_result = []; } @@ -292,16 +295,26 @@ public function create_site( Site $source_site, $assoc_args ): EE\ProcessRun { return $new_site; } + // True when `ee site list` failed specifically because the host has no sites. + // `\EE::error( 'No sites found!' )` exits 1 and writes to stderr (merged into stdout under `ssh -t`). + private function is_no_sites_error( EE\ProcessRun $result ): bool { + if ( 1 !== $result->return_code ) { + return false; + } + + $output = trim( preg_replace( '#\\x1b[[][^A-Za-z]*[A-Za-z]#', '', $result->stderr . $result->stdout ) ); + + return false !== strpos( $output, 'Error: No sites found!' ); + } + public function site_exists(): bool { $site_list = $this->execute( 'ee site list --format=json --no-color' ); - if ( 0 !== $site_list->return_code ) { - // "No sites found!" is emitted on stderr (merged into stdout under `ssh -t`). - $output = trim( preg_replace( '#\\x1b[[][^A-Za-z]*[A-Za-z]#', '', $site_list->stderr . $site_list->stdout ) ); - if ( false !== strpos( $output, 'Error: No sites found!' ) ) { - return false; - } + if ( $this->is_no_sites_error( $site_list ) ) { + return false; + } + if ( 0 !== $site_list->return_code ) { throw new \Exception( 'Unable to get site list on ' . $this->user . '@' . $this->host ); } From 0f7f0a295903c5069801b353fd18bab50a1dff01 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 10:41:58 +0000 Subject: [PATCH 3/8] fix(clone): surface unparseable site list in parent-site check validate_parent_site_present_on_host() treated any non-JSON 'ee site list' output with exit code 0 as an empty host, so garbled output (e.g. stderr noise merged into stdout under ssh -t) was still reported as 'Parent site not found'. Only the 'No sites found!' error now counts as an empty host. --- src/clone/Cloner.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/clone/Cloner.php b/src/clone/Cloner.php index 86d2b4a3..b11538bb 100644 --- a/src/clone/Cloner.php +++ b/src/clone/Cloner.php @@ -86,7 +86,7 @@ public function validate_parent_site_present_on_host( string $site ): void { if ( ! is_array( $list_result ) ) { // An empty host has no parent to match; any other failure must surface, not be masked as "parent not found". - if ( 0 !== $list_run->return_code && ! $this->is_no_sites_error( $list_run ) ) { + if ( ! $this->is_no_sites_error( $list_run ) ) { throw new \Exception( 'Unable to get site list on ' . $this->user . '@' . $this->host ); } $list_result = []; From 4df2cbe0a0d936b01668f6402665dcca5de839f6 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 16:45:50 +0000 Subject: [PATCH 4/8] fix(clone): treat an unparseable site list as an error in site_exists site_exists() read `ee site list` output that exited 0 but wasn't JSON (e.g. shell-rc noise under ssh -t) as "site doesn't exist". For an existing destination site, the clone then ran `ee site create`, which failed, and the create step's rollback ran `ee site delete --yes` on that existing site. Throw "Unable to get site list" instead, like the parent-site check does. --- src/clone/Cloner.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/clone/Cloner.php b/src/clone/Cloner.php index b11538bb..972e4c4f 100644 --- a/src/clone/Cloner.php +++ b/src/clone/Cloner.php @@ -320,6 +320,11 @@ public function site_exists(): bool { $sites = json_decode( $site_list->stdout, true ); + // Unparseable output must not read as "site doesn't exist": a clone rollback would then delete an existing destination site. + if ( ! is_array( $sites ) ) { + throw new \Exception( 'Unable to get site list on ' . $this->user . '@' . $this->host ); + } + foreach ( $sites as $site ) { if ( $site['site'] === $this->name ) { if ( 'disabled' === $site['status'] ) { From be6aab870f1f7776eaea654b9dfa47406a9a65f3 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 24 Sep 2026 16:46:19 +0000 Subject: [PATCH 5/8] fix(clone): accept an int wildcard flag in the parent-site check On PHP 8.1+ PDO SQLite returns native ints, so `ee site info --format=json` gives `site_ssl_wildcard: 1` and the strict `=== '1'` check never matched. Same-name `--ssl=inherit` clones to another host then always failed with "Parent site not found", even when the parent had wildcard SSL. Check both fields with `empty()` instead. --- src/clone/Cloner.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/clone/Cloner.php b/src/clone/Cloner.php index 972e4c4f..f2cc55f1 100644 --- a/src/clone/Cloner.php +++ b/src/clone/Cloner.php @@ -100,7 +100,8 @@ public function validate_parent_site_present_on_host( string $site ): void { if ( explode( '.', $site, 2 )[1] === $parent_site ) { $info_result = $this->execute( 'ee site info ' . $parent_site . ' --format=json' ); $info_result = json_decode( $info_result->stdout, true ); - if ( $info_result['site_ssl'] !== '' && $info_result['site_ssl_wildcard'] === '1' ) { + // site_ssl_wildcard is int 1 or string '1' depending on the PHP version (PDO SQLite typing). + if ( ! empty( $info_result['site_ssl'] ) && ! empty( $info_result['site_ssl_wildcard'] ) ) { return; } } From 0b16baa472d67e45f4cc1ee818f21596177aa790 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Wed, 14 Oct 2026 20:47:51 +0530 Subject: [PATCH 6/8] fix(clone): only roll back a destination site that this clone created The create step's rollback ran ee site delete even when this run's ee site create had failed, and the admin-tools and mailhog steps still ran afterwards. A site list that parses but doesn't show the site, or a site created in between, then let a failed clone delete or reconfigure a site it never made. Remove the site only after a successful create, and stop before admin-tools and mailhog when the create failed. --- src/clone/Cloner.php | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/clone/Cloner.php b/src/clone/Cloner.php index b355cdcc..a5777ba4 100644 --- a/src/clone/Cloner.php +++ b/src/clone/Cloner.php @@ -276,18 +276,28 @@ public function create_site( Site $source_site, $assoc_args ): EE\ProcessRun { EE::debug( 'Creating site "' . $this->name . '" on "' . $this->host . '"' ); $new_site = ''; + $created = false; $this->ensure_site_not_exists(); - $this->rsp->add_step( 'clone-create-site', function () use ( $source_site, $assoc_args, &$new_site ) { + $this->rsp->add_step( 'clone-create-site', function () use ( $source_site, $assoc_args, &$new_site, &$created ) { $new_site = $this->execute( $this->get_site_create_command( $source_site, $assoc_args ) ); + $created = 0 === $new_site->return_code; $this->set_site_details(); - }, function () { - $this->execute( 'ee site delete --skip-tty --yes ' . escapeshellarg( $this->name ) ); + }, function () use ( &$created ) { + // A failed create may mean the name was already taken; only remove a site this run made. + if ( $created ) { + $this->execute( 'ee site delete --skip-tty --yes ' . escapeshellarg( $this->name ) ); + } } ); if ( ! $this->rsp->execute() ) { throw new \Exception( 'Unable to create site.' ); } + // Don't set up admin-tools or mailhog on a site this run didn't create. + if ( ! $created ) { + return $new_site; + } + $admin_tools_command = $this->get_admin_tools_command( $source_site, $assoc_args ); if ( ! empty( $admin_tools_command ) ) { EE::log( 'Setting up admin-tools' ); From 8234a312ced8ad255646c386eac9bbc73d1bb05c Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Wed, 14 Oct 2026 20:52:58 +0530 Subject: [PATCH 7/8] fix(clone): accept only a JSON list of site rows as the site list A JSON object also decodes to a PHP array, so {} read as an empty list, and a row that isn't an array with a site name hit a string offset (a TypeError on PHP 8). Both site_exists() and the parent-site check now treat such output as an unreadable site list. --- src/clone/Cloner.php | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/clone/Cloner.php b/src/clone/Cloner.php index a5777ba4..33b6459f 100644 --- a/src/clone/Cloner.php +++ b/src/clone/Cloner.php @@ -88,10 +88,10 @@ public function validate_ee_version(): void { } public function validate_parent_site_present_on_host( string $site ): void { - $list_run = $this->execute( 'ee site list --format=json' ); - $list_result = json_decode( $list_run->stdout, true ); + $list_run = $this->execute( 'ee site list --format=json' ); + $list_result = self::parse_site_list( $list_run->stdout ); - if ( ! is_array( $list_result ) ) { + if ( null === $list_result ) { // An empty host has no parent to match; any other failure must surface, not be masked as "parent not found". if ( ! $this->is_no_sites_error( $list_run ) ) { throw new \Exception( 'Unable to get site list on ' . $this->user . '@' . $this->host ); @@ -343,6 +343,22 @@ private function is_no_sites_error( EE\ProcessRun $result ): bool { return false !== strpos( $output, 'Error: No sites found!' ); } + // The rows of `ee site list --format=json` output, or null when it isn't a JSON list of site rows. + private static function parse_site_list( string $stdout ) { + $sites = json_decode( $stdout, true ); + // A JSON object decodes to an array too. + if ( ! is_array( $sites ) || '[' !== substr( ltrim( $stdout ), 0, 1 ) ) { + return null; + } + foreach ( $sites as $site ) { + if ( ! is_array( $site ) || ! isset( $site['site'] ) || ! is_string( $site['site'] ) ) { + return null; + } + } + + return $sites; + } + public function site_exists(): bool { $site_list = $this->execute( 'ee site list --format=json --no-color' ); @@ -354,10 +370,10 @@ public function site_exists(): bool { throw new \Exception( 'Unable to get site list on ' . $this->user . '@' . $this->host ); } - $sites = json_decode( $site_list->stdout, true ); + $sites = self::parse_site_list( $site_list->stdout ); // Unparseable output must not read as "site doesn't exist": a clone rollback would then delete an existing destination site. - if ( ! is_array( $sites ) ) { + if ( null === $sites ) { throw new \Exception( 'Unable to get site list on ' . $this->user . '@' . $this->host ); } From 9e67308c1594edf0ad1e33b83cd858c37e033f07 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Wed, 14 Oct 2026 21:03:17 +0530 Subject: [PATCH 8/8] fix(clone): read the site list in one place for both checks The parent-site check trusted a failed ee site list run when its output still parsed, and a row without a valid status passed the parse and left the disabled-site check reading a missing key. site_exists() and the parent-site check now share get_site_list(): no sites gives an empty list, and a non-zero exit or anything but a list of site rows with an enabled or disabled status is an unreadable site list. --- src/clone/Cloner.php | 39 +++++++++++++++------------------------ 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/src/clone/Cloner.php b/src/clone/Cloner.php index 33b6459f..18a2aa44 100644 --- a/src/clone/Cloner.php +++ b/src/clone/Cloner.php @@ -88,18 +88,8 @@ public function validate_ee_version(): void { } public function validate_parent_site_present_on_host( string $site ): void { - $list_run = $this->execute( 'ee site list --format=json' ); - $list_result = self::parse_site_list( $list_run->stdout ); - - if ( null === $list_result ) { - // An empty host has no parent to match; any other failure must surface, not be masked as "parent not found". - if ( ! $this->is_no_sites_error( $list_run ) ) { - throw new \Exception( 'Unable to get site list on ' . $this->user . '@' . $this->host ); - } - $list_result = []; - } - - foreach ( $list_result as $site_details ) { + // An empty host has no parent to match; any other failure surfaces instead of reading as "parent not found". + foreach ( $this->get_site_list() as $site_details ) { $parent_site = $site_details['site']; $substr_match = strpos( $site, $parent_site ); @@ -351,7 +341,7 @@ private static function parse_site_list( string $stdout ) { return null; } foreach ( $sites as $site ) { - if ( ! is_array( $site ) || ! isset( $site['site'] ) || ! is_string( $site['site'] ) ) { + if ( ! is_array( $site ) || ! isset( $site['site'], $site['status'] ) || ! is_string( $site['site'] ) || ! in_array( $site['status'], [ 'enabled', 'disabled' ], true ) ) { return null; } } @@ -359,25 +349,26 @@ private static function parse_site_list( string $stdout ) { return $sites; } - public function site_exists(): bool { - $site_list = $this->execute( 'ee site list --format=json --no-color' ); - - if ( $this->is_no_sites_error( $site_list ) ) { - return false; - } + // The host's sites ([] when it has none). + private function get_site_list(): array { + $result = $this->execute( 'ee site list --format=json --no-color' ); - if ( 0 !== $site_list->return_code ) { - throw new \Exception( 'Unable to get site list on ' . $this->user . '@' . $this->host ); + if ( $this->is_no_sites_error( $result ) ) { + return []; } - $sites = self::parse_site_list( $site_list->stdout ); + $sites = 0 === $result->return_code ? self::parse_site_list( $result->stdout ) : null; - // Unparseable output must not read as "site doesn't exist": a clone rollback would then delete an existing destination site. + // Unreadable output must not read as "site doesn't exist": a clone rollback would then delete an existing destination site. if ( null === $sites ) { throw new \Exception( 'Unable to get site list on ' . $this->user . '@' . $this->host ); } - foreach ( $sites as $site ) { + return $sites; + } + + public function site_exists(): bool { + foreach ( $this->get_site_list() as $site ) { if ( $site['site'] === $this->name ) { if ( 'disabled' === $site['status'] ) { throw new \Exception( 'The site that you want to clone has been disabled. Please enable the site and clone again.' );