PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.8
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.8
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / modules / Migration / MigrationModule.php

MigrationModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.1.8, at includes/modules/Migration/MigrationModule.php

739 lines 28.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Migration module — one-click settings import from other caching
4 * plugins (WP Rocket, W3 Total Cache, WP Super Cache).
5 *
6 * Tier: Pro per FEATURES.md "Migration" — both rows tagged Pro
7 * (cross-plugin importer is a non-trivial value-add; LiteSpeed
8 * doesn't have one).
9 *
10 * @package XSpeed
11 */
12
13 declare(strict_types=1);
14
15 namespace XSpeed\Modules\Migration;
16
17 defined( 'ABSPATH' ) || exit;
18
19 use XSpeed\Module;
20 use XSpeed\Migration;
21
22 final class MigrationModule extends Module {
23
24 public const SLUG = 'migration';
25 public const TIER = self::TIER_FREE;
26 public const VERSION = '1.0.0';
27
28 public function ui_metadata(): array {
29 return array(
30 'label' => 'Migration',
31 'icon' => 'Import',
32 'description' => 'Import settings from WP Rocket, W3 Total Cache, or WP Super Cache.',
33 'custom_panel' => 'MigrationPanel',
34 );
35 }
36
37 public function settings_schema(): array {
38 return array();
39 }
40
41 /**
42 * Per-user meta key recording which detected source the user dismissed
43 * the migration notice for. Keyed by source so dismissing the LiteSpeed
44 * prompt doesn't hide a later WP Rocket prompt.
45 */
46 private const DISMISS_META = 'xspeed_migration_notice_dismissed';
47
48 /** Query arg used by the one-click dismiss link. */
49 private const DISMISS_ARG = 'xspeed_dismiss_migration';
50
51 /**
52 * Per-user list of source ids the user has already SEEN (by opening the
53 * Migration panel). Seen sources don't count toward the sidebar badge —
54 * the badge means "new importable plugins you haven't looked at yet", so
55 * it clears once the user visits the page. A plugin installed LATER is
56 * still un-seen, so it re-badges.
57 */
58 private const SEEN_META = 'xspeed_migration_seen_sources';
59
60 public function boot(): void {
61 // Dashboard nudge: when another caching plugin is detected, offer a
62 // one-click import — the same "we noticed you use X" prompt other
63 // plugins show. Renders on standard WP admin screens (NOT xSpeed's
64 // own pages, where the Migration panel already covers it).
65 add_action( 'admin_notices', array( $this, 'maybe_render_notice' ) );
66 add_action( 'admin_init', array( $this, 'handle_dismiss' ) );
67 // Sidebar attention badge: surface the count of importable plugins on
68 // the Migration nav item so the user knows there's an action to take.
69 add_filter( 'xspeed_module_descriptor', array( $this, 'add_sidebar_badge' ), 10, 2 );
70 }
71
72 /**
73 * Badge the Migration module's sidebar item with the count of importable
74 * caching plugins the user hasn't SEEN or dismissed yet — surfaces at a
75 * glance how many NEW sources they could migrate from. Opening the panel
76 * marks sources seen (see rest_status), so the badge clears after a visit.
77 * Other modules untouched.
78 *
79 * @param array $entry Module descriptor being built.
80 * @param object $module The module instance.
81 * @return array
82 */
83 public function add_sidebar_badge( array $entry, $module ): array {
84 if ( ( $entry['slug'] ?? '' ) !== self::SLUG ) {
85 return $entry;
86 }
87 $uid = get_current_user_id();
88 $dismissed = (array) get_user_meta( $uid, self::DISMISS_META, true );
89 $seen = (array) get_user_meta( $uid, self::SEEN_META, true );
90 $count = 0;
91 foreach ( $this->detected_sources( $dismissed ) as $s ) {
92 if ( ! in_array( $s['id'], $seen, true ) ) {
93 ++$count;
94 }
95 }
96 if ( $count > 0 ) {
97 $entry['badge'] = $count;
98 }
99 return $entry;
100 }
101
102 /**
103 * Render the migration nudge on the dashboard when exactly one importable
104 * source is detected and the user hasn't dismissed it. Kept deliberately
105 * conservative: skipped on xSpeed's own screens, for users without
106 * manage_options, and once dismissed.
107 */
108 public function maybe_render_notice(): void {
109 if ( ! current_user_can( 'manage_options' ) ) {
110 return;
111 }
112 // Don't double up on xSpeed's own pages — the Migration panel is right there.
113 if ( class_exists( '\\XSpeed\\Admin' ) && \XSpeed\Admin::is_plugin_page() ) {
114 return;
115 }
116
117 $dismissed = (array) get_user_meta( get_current_user_id(), self::DISMISS_META, true );
118 $detected = $this->detected_sources( $dismissed );
119 if ( empty( $detected ) ) {
120 return;
121 }
122
123 $brand = $this->branding_name();
124 $base_url = admin_url( 'admin.php?page=xspeed' );
125 // The dashboard selects the panel from the URL hash. The hash must be
126 // the LAST thing in the URL — any query arg (e.g. ?source=…) has to go
127 // BEFORE the '#', or it becomes part of the fragment ("migration?source=…")
128 // which no module slug matches, so the app falls back to the first
129 // panel (#cache). That was the "Import goes to #cache" bug.
130 $panel_url = $base_url . '#migration';
131 $dismiss_url = wp_nonce_url(
132 add_query_arg( self::DISMISS_ARG, 'all' ),
133 'xspeed_dismiss_migration_all'
134 );
135 $count = count( $detected );
136
137 // Branded card. All inline-styled (admin-notice context has no
138 // bundled stylesheet) but mapped to DESIGN.md tokens: accent #2563eb,
139 // neutral text #1e293b / #475569, rounded-lg, comfortable padding.
140 $heading = sprintf(
141 /* translators: %d: number of detected caching plugins. */
142 _n(
143 'Migrate to %1$s — %2$d caching plugin detected',
144 'Migrate to %1$s — %2$d caching plugins detected',
145 $count,
146 'xspeed'
147 ),
148 $brand,
149 $count
150 );
151
152 $brand_color = $this->brand_color();
153 // Brand-color the Import CTAs (override WP's default blue primary).
154 // This notice is echoed directly (not through wp_kses), so an inline
155 // <style> block is fine here.
156 echo '<style>.xspeed-migration-notice .xspeed-mig-cta.button-primary{'
157 . 'background:' . esc_attr( $brand_color ) . ' !important;'
158 . 'border-color:' . esc_attr( $brand_color ) . ' !important;box-shadow:none !important;'
159 . 'box-sizing:border-box !important;min-height:36px !important;max-height:36px !important;height:36px !important;line-height:1 !important;padding-top:0;padding-bottom:0;display:inline-flex;align-items:center;}'
160 . '.xspeed-migration-notice .xspeed-mig-cta.button-primary:hover{filter:brightness(1.15);}'
161 . '</style>';
162 echo '<div class="notice xspeed-migration-notice" style="padding:0;border:1px solid #e2e8f0;border-left:4px solid ' . esc_attr( $brand_color ) . ';border-radius:8px;overflow:hidden;background:#fff;">';
163 echo '<div style="padding:16px 18px;">';
164
165 // Header row: brand mark + heading.
166 echo '<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">';
167 $logo = $this->branding_logo();
168 if ( '' !== $logo ) {
169 echo '<span style="display:inline-flex;width:24px;height:24px;flex:0 0 24px;">' . $logo . '</span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- logo is a sanitized inline SVG from branding, escaped at source.
170 }
171 echo '<strong style="font-size:14px;color:#1e293b;">' . esc_html( $heading ) . '</strong>';
172 echo '</div>';
173
174 // Says what the button does and what it does NOT do. The old copy
175 // mentioned only the import while the REST route also deactivated the
176 // source — this notice is the first and most-seen touchpoint, so it
177 // undersold a destructive action. Deactivation is now opt-in, and the
178 // copy states that rather than leaving it to be inferred. (#189)
179 echo '<p style="margin:0 0 12px;color:#475569;font-size:13px;">'
180 . esc_html__( 'Import your existing settings instead of configuring everything by hand. You choose what happens to the old plugin — switch it off (recommended, since two page caches conflict) or leave it running. Pick a source to migrate:', 'xspeed' )
181 . '</p>';
182
183 // One row per detected source: label + value count + its own Import button.
184 echo '<div style="display:flex;flex-direction:column;gap:8px;">';
185 foreach ( $detected as $s ) {
186 // Query arg BEFORE the hash so the dashboard still reads #migration.
187 $src_url = $base_url . '&source=' . rawurlencode( $s['id'] ) . '#migration';
188 echo '<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;">';
189 $mapped = (int) ( $s['mapped_count'] ?? 0 );
190 echo '<span style="font-size:13px;color:#1e293b;"><strong>' . esc_html( $s['label'] ) . '</strong>'
191 . ' <span style="color:#94a3b8;">'
192 . esc_html(
193 sprintf(
194 /* translators: %d: number of settings xSpeed will actually import. */
195 _n( 'imports %d setting', 'imports %d settings', $mapped, 'xspeed' ),
196 $mapped
197 )
198 )
199 . '</span></span>';
200 echo '<a href="' . esc_url( $src_url ) . '" class="button button-primary xspeed-mig-cta" style="flex:0 0 auto;">'
201 . esc_html__( 'Import', 'xspeed' ) . '</a>';
202 echo '</div>';
203 }
204 echo '</div>';
205
206 // Footer: open the full panel + dismiss the whole notice.
207 echo '<p style="margin:12px 0 0;display:flex;gap:16px;align-items:center;">';
208 echo '<a href="' . esc_url( $panel_url ) . '" style="font-size:13px;">' . esc_html__( 'Open Migration panel', 'xspeed' ) . '</a>';
209 echo '<a href="' . esc_url( $dismiss_url ) . '" style="font-size:13px;color:#94a3b8;text-decoration:none;">' . esc_html__( 'Dismiss', 'xspeed' ) . '</a>';
210 echo '</p>';
211
212 echo '</div></div>';
213 }
214
215 /**
216 * Detected sources that are still actionable — not dismissed and not
217 * already imported — richest first. These are what the dashboard notice
218 * and the sidebar badge count: "new caching plugins you could migrate
219 * from". Once imported, a source drops out.
220 *
221 * @param string[] $dismissed Dismissed source ids ('all' hides every one).
222 * @return array<int,array{id:string,label:string,mapped_count:int}>
223 */
224 private function detected_sources( array $dismissed = array() ): array {
225 if ( in_array( 'all', $dismissed, true ) ) {
226 return array();
227 }
228 $out = array();
229 foreach ( Migration::status() as $s ) {
230 if ( empty( $s['detected'] ) || ! empty( $s['imported'] ) || in_array( $s['id'], $dismissed, true ) ) {
231 continue;
232 }
233 $out[] = $s;
234 }
235 // Order by the honest mapped count (what we actually import).
236 usort( $out, static fn( $a, $b ) => (int) $b['mapped_count'] <=> (int) $a['mapped_count'] );
237 return $out;
238 }
239
240 /** Inline brand logo SVG when white-label supplies one; else empty. */
241 private function branding_logo(): string {
242 $brand = apply_filters( 'xspeed_branding', array() );
243 return isset( $brand['logo_svg'] ) && is_string( $brand['logo_svg'] ) ? $brand['logo_svg'] : '';
244 }
245
246 /** Persist the per-source dismissal when the user clicks our Dismiss link. */
247 public function handle_dismiss(): void {
248 if ( ! isset( $_GET[ self::DISMISS_ARG ] ) || ! current_user_can( 'manage_options' ) ) {
249 return;
250 }
251 $source = sanitize_key( wp_unslash( $_GET[ self::DISMISS_ARG ] ) );
252 if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'xspeed_dismiss_migration_' . $source ) ) {
253 return;
254 }
255 $uid = get_current_user_id();
256 $dismissed = (array) get_user_meta( $uid, self::DISMISS_META, true );
257 if ( ! in_array( $source, $dismissed, true ) ) {
258 $dismissed[] = $source;
259 update_user_meta( $uid, self::DISMISS_META, $dismissed );
260 }
261 // Redirect to drop the query args so a reload doesn't re-trigger.
262 wp_safe_redirect( remove_query_arg( array( self::DISMISS_ARG, '_wpnonce' ) ) );
263 exit;
264 }
265
266 /**
267 * The single most relevant detected source to nudge about, or null.
268 * Picks the detected source with the most settings (the richest import),
269 * skipping any the user has already dismissed — so dismissing the top
270 * prompt surfaces the next source rather than going silent while another
271 * importable plugin is still present. Only one prompt at a time keeps the
272 * dashboard uncluttered.
273 *
274 * @param string[] $dismissed Source ids the user has dismissed.
275 * @return array{id:string,label:string,value_count:int}|null
276 */
277 private function top_detected_source( array $dismissed = array() ): ?array {
278 $best = null;
279 foreach ( Migration::status() as $s ) {
280 if ( empty( $s['detected'] ) || in_array( $s['id'], $dismissed, true ) ) {
281 continue;
282 }
283 if ( null === $best || (int) $s['value_count'] > (int) $best['value_count'] ) {
284 $best = $s;
285 }
286 }
287 return $best;
288 }
289
290 /** Brand name honoring Pro white-label, falling back to "xSpeed". */
291 private function branding_name(): string {
292 $brand = apply_filters( 'xspeed_branding', array() );
293 return isset( $brand['name'] ) && '' !== $brand['name'] ? (string) $brand['name'] : 'xSpeed';
294 }
295
296 /**
297 * Brand/logo color for the notice accent + Import buttons. White-label
298 * sites can set `brand_color` via the xspeed_branding filter; otherwise
299 * we use the xSpeed logo color (near-black), not the design blue accent —
300 * the notice should match the on-screen logo. (FBS-82379)
301 */
302 private function brand_color(): string {
303 $brand = apply_filters( 'xspeed_branding', array() );
304 $color = isset( $brand['brand_color'] ) ? (string) $brand['brand_color'] : '';
305 return preg_match( '/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $color ) ? $color : '#1e1e1e';
306 }
307
308 public function rest_routes(): array {
309 return array(
310 array(
311 'path' => '/status',
312 'methods' => 'GET',
313 'callback' => array( $this, 'rest_status' ),
314 ),
315 array(
316 'path' => '/preview',
317 'methods' => 'POST',
318 'callback' => array( $this, 'rest_preview' ),
319 ),
320 array(
321 'path' => '/apply',
322 'methods' => 'POST',
323 'callback' => array( $this, 'rest_apply' ),
324 ),
325 );
326 }
327
328 public function rest_status( \WP_REST_Request $request ) {
329 $status = Migration::status();
330 // Opening the Migration panel triggers this call — treat it as the
331 // user having SEEN every currently-detected source, which clears the
332 // sidebar count badge. Record the detected ids against the user.
333 $this->mark_sources_seen( $status );
334 return rest_ensure_response( array( 'sources' => $status ) );
335 }
336
337 /**
338 * Record the currently-detected source ids as seen for this user, so the
339 * sidebar badge stops counting them. Merges with any prior seen set.
340 *
341 * @param array $status Output of Migration::status().
342 */
343 private function mark_sources_seen( array $status ): void {
344 $uid = get_current_user_id();
345 if ( ! $uid ) {
346 return;
347 }
348 $seen = (array) get_user_meta( $uid, self::SEEN_META, true );
349 $add = array();
350 foreach ( $status as $s ) {
351 if ( ! empty( $s['detected'] ) ) {
352 $add[] = $s['id'];
353 }
354 }
355 $merged = array_values( array_unique( array_merge( $seen, $add ) ) );
356 if ( $merged !== $seen ) {
357 update_user_meta( $uid, self::SEEN_META, $merged );
358 }
359 }
360
361 public function rest_preview( \WP_REST_Request $request ) {
362 $params = $request->get_json_params();
363 $source = isset( $params['source'] ) ? (string) $params['source'] : '';
364 $patch = Migration::preview( $source );
365 if ( null === $patch ) {
366 return new \WP_Error( 'xspeed_pro_mig_no_source', 'Source not detected or unknown.', array( 'status' => 404 ) );
367 }
368 return rest_ensure_response( array( 'patch' => $patch ) );
369 }
370
371 public function rest_apply( \WP_REST_Request $request ) {
372 $params = $request->get_json_params();
373 $source = isset( $params['source'] ) ? (string) $params['source'] : '';
374 if ( '' === $source ) {
375 return new \WP_Error( 'xspeed_pro_mig_no_source', 'Provide a source id.', array( 'status' => 400 ) );
376 }
377
378 /*
379 * Deactivating the source is the CALLER's decision, and it defaults to
380 * NO. (#189)
381 *
382 * This used to happen unconditionally: the request carried only
383 * `source`, so the server could not distinguish "the user clicked
384 * through our warning" from any other POST to this route. The only
385 * guard rail was an InlineConfirm in the React client, which is the
386 * wrong layer for a destructive action — and WP-CLI and MCP, hitting
387 * the same product action, did the opposite and left the plugin on.
388 *
389 * Defaulting to false rather than true is what makes the documented
390 * contract true again (docs/user/advanced-migration.md said migration
391 * "never changes" the old plugin) and matches the house rule that we
392 * never modify another plugin's state on our own initiative. The panel
393 * now passes deactivate:true explicitly after its confirm, so the
394 * common path is unchanged for users.
395 */
396 $deactivate = ! empty( $params['deactivate_source'] );
397
398 $results = Migration::apply( $source );
399
400 $deactivated = false;
401 $source_label = '';
402 foreach ( Migration::status() as $s ) {
403 if ( $s['id'] === $source ) {
404 $source_label = (string) $s['label'];
405 break;
406 }
407 }
408
409 /*
410 * Did the import actually cover anything?
411 *
412 * Gate on `applied`, NOT on `ok`. `ok` is update_option()'s return
413 * value, which is FALSE when the stored value did not change — so a
414 * re-import of settings already in place reports ok:false on every
415 * module while having succeeded completely. Gating on `ok` therefore
416 * refused to deactivate after a perfectly good second import, which
417 * is how this read on a live site: {"cache":{"ok":false,"applied":
418 * ["cache_expiry","excluded_urls"]}}.
419 *
420 * `applied` lists the keys the import decided were meaningful, so a
421 * non-empty one means the source really was read and mapped. An empty
422 * $results (unknown source, nothing meaningful) still blocks
423 * deactivation, which is the case that matters: never switch a plugin
424 * off on the back of an import that did nothing. (#189)
425 */
426 $imported_something = false;
427 foreach ( (array) $results as $info ) {
428 if ( is_array( $info ) && ! empty( $info['applied'] ) ) {
429 $imported_something = true;
430 break;
431 }
432 }
433
434 $refused = '';
435 $refused_message = '';
436 if ( $deactivate && $imported_something ) {
437 if ( ! $this->can_deactivate( $source ) ) {
438 // Not an error: the import succeeded and is the thing the user
439 // asked for. Report the refusal so the panel can say why the
440 // plugin is still on rather than silently implying it is off.
441 $refused = 'insufficient_capability';
442
443 // Name WHO can do it, not just that the caller cannot. On a
444 // network-activated source the answer is specifically a network
445 // administrator, and a site admin has no way to work that out
446 // from a bare capability code. (#189 AC5)
447 $file = Migration::plugin_file( $source );
448 $network_scoped = is_multisite() && '' !== $file && is_plugin_active_for_network( $file );
449 $refused_message = $network_scoped
450 ? sprintf(
451 /* translators: %s: source plugin label. */
452 __( '%s is activated across the whole network, so only a network administrator can switch it off. Your settings were imported — ask a network administrator to deactivate it.', 'xspeed' ),
453 $source_label
454 )
455 : sprintf(
456 /* translators: %s: source plugin label. */
457 __( 'Your account can change settings but not switch plugins off, so %s is still active. Your settings were imported — ask an administrator to deactivate it.', 'xspeed' ),
458 $source_label
459 );
460 } else {
461 $deactivated = $this->deactivate_source( $source );
462 }
463 }
464
465 // The user declined (or was refused) and the source is still running.
466 // Record it so the warning OUTLIVES this screen — see pending_source().
467 // Called on every import, not just the declining ones: the helper
468 // checks the plugin's live state and clears itself when it is off, so
469 // a later "import and switch" also resolves an earlier warning.
470 if ( $imported_something ) {
471 Migration::remember_active_source( $source, $source_label );
472 }
473
474 if ( class_exists( '\\XSpeed\\Activity_Log' ) && ! empty( $results ) ) {
475 \XSpeed\Activity_Log::record(
476 'migration_applied',
477 $deactivated
478 ? sprintf( 'Imported settings from %1$s and deactivated it.', $source_label )
479 : sprintf( 'Imported settings from %s.', $source_label ),
480 \XSpeed\Activity_Log::INFO
481 );
482 }
483
484 return rest_ensure_response(
485 array(
486 'results' => $results,
487 'deactivated' => $deactivated,
488 'source_label' => $source_label,
489 // Empty unless we were asked to deactivate and declined to.
490 // The panel needs to distinguish "you didn't ask" from "you
491 // asked and you may not", or it would report the source as
492 // still active with no explanation.
493 'refused' => $refused,
494 // A ready-to-show sentence naming who CAN do it. The panel
495 // prints this verbatim rather than mapping codes to copy, so
496 // the network-vs-site distinction stays in one place.
497 'refused_message' => $refused_message,
498 )
499 );
500 }
501
502 /**
503 * May the CURRENT user switch this source plugin off?
504 *
505 * The route itself only requires `manage_options` (the module default),
506 * which is right for importing settings — that writes nothing but our own
507 * options. Deactivating somebody else's plugin is a different act, and WP
508 * core guards its own plugins screen with `activate_plugins`, escalating
509 * to `manage_network_plugins` for a network-active plugin.
510 *
511 * Without this check a subsite Administrator — who has manage_options but
512 * neither of those — could deactivate a NETWORK-ACTIVE caching plugin
513 * across every site in the network with one REST call. Reproduced on a
514 * live multisite install for #189; core would have refused the same user
515 * on wp-admin/plugins.php.
516 *
517 * @param string $source Source id.
518 */
519 private function can_deactivate( string $source ): bool {
520 $file = Migration::plugin_file( $source );
521 if ( '' === $file ) {
522 return false;
523 }
524
525 foreach ( array( 'plugin.php' ) as $inc ) {
526 require_once ABSPATH . 'wp-admin/includes/' . $inc;
527 }
528
529 // Network-active plugins are a network-level object: deactivating one
530 // affects every site, so it needs the network capability regardless of
531 // how much power the caller holds on this one site.
532 if ( is_multisite() && is_plugin_active_for_network( $file ) ) {
533 return current_user_can( 'manage_network_plugins' );
534 }
535
536 return current_user_can( 'activate_plugins' );
537 }
538
539 /**
540 * Deactivate the source caching plugin (network-wide on multisite).
541 * Returns true only if it was active and is now off.
542 *
543 * Callers MUST gate this on can_deactivate() — it performs no capability
544 * check of its own, because the CLI path resolves permission differently
545 * (a WP-CLI operator is root by definition and has no current user).
546 *
547 * @param string $source Source id.
548 * @return bool
549 */
550 private function deactivate_source( string $source ): bool {
551 // One home for this map, shared with Migration::status()'s active
552 // flag. A private copy here could drift and deactivate a plugin the
553 // panel had reported as inactive. (#189)
554 $file = Migration::plugin_file( $source );
555 if ( '' === $file ) {
556 return false;
557 }
558 // deactivate_plugins() fires each plugin's deactivation hook, and some
559 // (e.g. WP Super Cache) call admin-only helpers like get_home_path()
560 // in theirs. Those live in wp-admin/includes/file.php — NOT loaded
561 // during a REST request — so without these includes the deactivation
562 // hook fatals with "undefined function get_home_path()". Load the
563 // admin plumbing first so any source plugin's teardown runs cleanly.
564 foreach ( array( 'plugin.php', 'file.php', 'misc.php' ) as $inc ) {
565 require_once ABSPATH . 'wp-admin/includes/' . $inc;
566 }
567 if ( ! is_plugin_active( $file ) ) {
568 return false;
569 }
570
571 /*
572 * Be EXPLICIT about scope rather than leaving $network_wide at null.
573 *
574 * Core evaluates `( false !== $network_wide ) && is_plugin_active_for_network()`,
575 * and `false !== null` is true — so the default silently takes the
576 * network-wide branch. That is the correct scope for a network-active
577 * plugin (a per-site deactivation would not turn it off anyway), but
578 * it should be a decision we state, not a fact of PHP's comparison
579 * rules. can_deactivate() has already required the matching
580 * capability for whichever branch this picks. (#189)
581 */
582 $network_wide = is_multisite() && is_plugin_active_for_network( $file );
583 deactivate_plugins( $file, false, $network_wide );
584
585 return ! is_plugin_active( $file );
586 }
587
588 public function cli_commands(): array {
589 return array(
590 array(
591 'name' => 'xspeed migrate',
592 'callback' => array( $this, 'cli_handler' ),
593 'shortdesc' => 'Import settings from another caching plugin.',
594 'synopsis' => array(
595 array(
596 'type' => 'positional',
597 'name' => 'action',
598 'options' => array( 'status', 'preview', 'apply' ),
599 'optional' => true,
600 ),
601 array(
602 'type' => 'assoc',
603 'name' => 'source',
604 'optional' => true,
605 ),
606 array(
607 'type' => 'flag',
608 'name' => 'deactivate-source',
609 'description' => 'After a successful import, also deactivate the source plugin. Off by default: running two page caches at once breaks both, but switching off another plugin is your call, not ours.',
610 'optional' => true,
611 ),
612 ),
613 ),
614 );
615 }
616
617 public function cli_handler( array $args, array $assoc ): void {
618 $action = $args[0] ?? 'status';
619 switch ( $action ) {
620 case 'status':
621 foreach ( Migration::status() as $s ) {
622 \WP_CLI::log( sprintf( '%-20s %s %d values', $s['id'], $s['detected'] ? 'DETECTED' : 'missing ', $s['value_count'] ) );
623 }
624 return;
625 case 'preview':
626 $src = (string) ( $assoc['source'] ?? '' );
627 $p = Migration::preview( $src );
628 if ( null === $p ) {
629 \WP_CLI::error( 'Source not detected or unknown: ' . $src );
630 }
631 \WP_CLI::log( wp_json_encode( $p, JSON_PRETTY_PRINT ) );
632 return;
633 case 'apply':
634 $src = (string) ( $assoc['source'] ?? '' );
635 $r = Migration::apply( $src );
636 if ( empty( $r ) ) {
637 \WP_CLI::error( 'Nothing imported.' );
638 }
639 foreach ( $r as $mod => $info ) {
640 /*
641 * "failed" was a lie. `ok` is update_option()'s return,
642 * which is false when the stored value did not CHANGE — so
643 * re-importing settings already in place printed
644 * "failed" for every module beside the list of fields it
645 * had just imported correctly. Report what actually
646 * happened instead. (#189)
647 */
648 $applied = (array) ( $info['applied'] ?? array() );
649 if ( empty( $applied ) ) {
650 $state = 'nothing to import';
651 } elseif ( ! empty( $info['ok'] ) ) {
652 $state = 'imported';
653 } else {
654 $state = 'already up to date';
655 }
656 \WP_CLI::log( sprintf( '%-20s %-18s %s', $mod, $state, implode( ',', $applied ) ) );
657 }
658
659 /*
660 * Same contract as REST: deactivate only when asked. This path
661 * used to never deactivate AND never say so, so an operator
662 * (or an AI through MCP `run_command`) finished with two page
663 * caches live on the site and nothing in the output to say it.
664 * That is the failure mode the troubleshooting docs describe
665 * as breaking caching for both plugins. (#189)
666 *
667 * No capability check here: a WP-CLI caller is root by
668 * definition and there is no current user to test. The gate
669 * that matters is on the REST route, which is the one a
670 * browser can reach.
671 */
672 // `applied`, not `ok` — see rest_apply() for why ok:false is a
673 // normal outcome of a successful re-import.
674 $imported_something = false;
675 foreach ( (array) $r as $info ) {
676 if ( is_array( $info ) && ! empty( $info['applied'] ) ) {
677 $imported_something = true;
678 break;
679 }
680 }
681
682 // WP-CLI normalises --deactivate-source to a 'deactivate-source'
683 // key; accept the underscore spelling too so MCP callers passing
684 // options as JSON don't have to guess which one we mean.
685 $want_off = ! empty( $assoc['deactivate-source'] ) || ! empty( $assoc['deactivate_source'] );
686 $file = Migration::plugin_file( $src );
687
688 require_once ABSPATH . 'wp-admin/includes/plugin.php';
689 $still_on = '' !== $file && is_plugin_active( $file );
690
691 if ( $want_off && $imported_something && $still_on ) {
692 if ( $this->deactivate_source( $src ) ) {
693 \WP_CLI::log( sprintf( 'Deactivated %s.', $src ) );
694 $still_on = false;
695 } else {
696 \WP_CLI::warning( sprintf( 'Could not deactivate %s.', $src ) );
697 }
698 }
699
700 if ( $still_on ) {
701 \WP_CLI::warning(
702 sprintf(
703 '%s is still active. Two page caches running together fight over the drop-in and can break caching for both — deactivate it, or re-run with --deactivate-source.',
704 $src
705 )
706 );
707 }
708
709 // Same persistent record as the REST path, so a CLI or MCP
710 // import that leaves the source running also raises the Health
711 // warning — the three surfaces must end in the same state for
712 // the same input. (#189 AC4, AC10)
713 if ( $imported_something ) {
714 $label = '';
715 foreach ( Migration::status() as $s ) {
716 if ( $s['id'] === $src ) {
717 $label = (string) $s['label'];
718 break;
719 }
720 }
721 Migration::remember_active_source( $src, $label );
722 }
723
724 \WP_CLI::success( 'Import complete.' );
725 return;
726 default:
727 // Without this, an unrecognised action fell out of the switch
728 // and returned success with no output — indistinguishable from
729 // "ran fine, nothing to report", and ok:true over MCP.
730 \WP_CLI::error(
731 sprintf(
732 'Unknown action "%s". Expected: status | preview --source=<id> | apply --source=<id>.',
733 $action
734 )
735 );
736 }
737 }
738 }
739