Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 781
0.00% covered (danger)
0.00%
0 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 2
wpcomsh_cli_confirm
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
wpcomsh_cli_get_plugins_with_status
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
wpcomsh_cli_save_deactivated_plugins_record
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
wpcomsh_cli_remove_expired_from_deactivation_record
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
wpcomsh_cli_reschedule_deactivated_list_cleanup
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
wpcomsh_cli_remember_plugin_deactivation
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
wpcomsh_cli_forget_plugin_deactivation
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
wpcomsh_cli_plugin_symlink
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
272
wpcomsh_cli_theme_symlink
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
272
wpcomsh_cli_launch_site
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
WPCOMSH_CLI_Commands
0.00% covered (danger)
0.00%
0 / 527
0.00% covered (danger)
0.00%
0 / 20
16002
0.00% covered (danger)
0.00%
0 / 1
 deactivate_user_installed_plugins
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
110
 reactivate_user_installed_plugins
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
132
 domain_name_changed
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 post_transfer
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 post_reset
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 post_clone
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 install_plugin_language_packs
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 persistent_data
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 purchases
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 headstart_terms
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 backup_import
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
42
 global_styles
0.00% covered (danger)
0.00%
0 / 66
0.00% covered (danger)
0.00%
0 / 1
462
 incompatible_plugins
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
42
 php_81_plugin_patch
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
56
 php_83_plugin_patch
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
110
 fatal_error_emails_disable
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 do_plugin_dance_health_check
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 plugin_dance
0.00% covered (danger)
0.00%
0 / 112
0.00% covered (danger)
0.00%
0 / 1
272
 plugin_dance_health_check
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 diagnostic
0.00% covered (danger)
0.00%
0 / 99
0.00% covered (danger)
0.00%
0 / 1
240
Checksum_Plugin_Command_WPCOMSH
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 1
 filter_file
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * CLI commands for wpcomsh.
4 *
5 * @package wpcomsh
6 */
7
8// phpcs:disable Universal.Files.SeparateFunctionsFromOO.Mixed
9
10/**
11 * Plugins that shouldn't be deactivated by the deactivate-user-plugins command.
12 */
13define(
14    'WPCOMSH_CLI_DONT_DEACTIVATE_PLUGINS',
15    array(
16        'akismet',
17        'classic-editor',
18        'full-site-editing',
19        'gutenberg',
20        'jetpack',
21        'layout-grid',
22        'page-optimize',
23        // Avoid deactivating the file shim before the Atomic media backfill is complete
24        'wpcom-file-shim',
25    )
26);
27
28/**
29 * ECommerce plan plugins that shouldn't be deactivated by deactivate-user-plugins
30 * when the site has an eCommerce plan.
31 */
32define(
33    'WPCOMSH_CLI_ECOMMERCE_PLAN_PLUGINS',
34    array(
35        'storefront-powerpack',
36        'woocommerce',
37        'facebook-for-woocommerce',
38        'mailchimp-for-woocommerce',
39        'woocommerce-services',
40        'woocommerce-product-addons',
41        'taxjar-simplified-taxes-for-woocommerce',
42    )
43);
44
45/**
46 * The option where we keep a list of plugins deactivated via wp-cli.
47 */
48define( 'WPCOMSH_CLI_OPTION_DEACTIVATED_USER_PLUGINS', 'wpcomsh_deactivated_user_installed_plugins' );
49
50/**
51 * We keep a record of plugins deactivated via wp-cli so we can reactivate them later
52 * with `wp wpcomsh reactivate-user-plugins`. This constant is the amount of time we'll
53 * consider a deactivation valid for reactivation via `reactivate-user-plugins`.
54 */
55define( 'WPCOMSH_CLI_PLUGIN_REACTIVATION_MAX_AGE', 14 * DAY_IN_SECONDS );
56
57define( 'WPCOMSH_CLI_DEACTIVATED_PLUGIN_RECORD_CLEANUP_JOB', 'wpcomsh_cli_cleanup_deactivated_user_plugin_record' );
58
59/**
60 * Don't allow `wp core multisite-install` or `wp core multisite-convert` to be run.
61 */
62WP_CLI::add_hook(
63    'before_run_command',
64    function () {
65        $runner            = WP_CLI::get_runner();
66        $disabled_commands = array(
67            array( 'core', 'multisite-install' ),
68            array( 'core', 'multisite-convert' ),
69        );
70        foreach ( $disabled_commands as $disabled_command ) {
71            if ( array_slice( $runner->arguments, 0, count( $disabled_command ) ) === $disabled_command ) {
72                WP_CLI::error(
73                    sprintf(
74                        'The \'%s\' command is disabled on this platform.',
75                        implode( ' ', $disabled_command )
76                    )
77                );
78            }
79        }
80    }
81);
82
83/**
84 * Ask the user to confirm a yes/no question.
85 *
86 * @param  string $question The yes/no question to ask the user.
87 * @return boolean Whether the user confirmed or not.
88 */
89function wpcomsh_cli_confirm( $question ) {
90    fwrite( STDOUT, $question . ' [Y/n] ' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
91    $answer = strtolower( trim( fgets( STDIN ) ) );
92    return 'y' === $answer || ! $answer;
93}
94
95/**
96 * Get the names of plugins with the specified status.
97 *
98 * @param string $status The plugin status to match.
99 *
100 * @return string[]|false An array of plugin names. `false` if there is an error.
101 */
102function wpcomsh_cli_get_plugins_with_status( $status ) {
103    $list_result = WP_CLI::runcommand(
104        "--skip-plugins --skip-themes plugin list --format=json --status=$status",
105        array(
106            'launch'     => false,
107            'return'     => 'all',
108            'exit_error' => false,
109        )
110    );
111    if ( 0 !== $list_result->return_code ) {
112        return false;
113    }
114
115    $decoded_result = json_decode( $list_result->stdout );
116    if ( null === $decoded_result ) {
117        return false;
118    }
119    if ( ! is_array( $decoded_result ) ) {
120        return false;
121    }
122
123    return array_map(
124        function ( $plugin ) {
125            return $plugin->name; },
126        $decoded_result
127    );
128}
129
130/**
131 * Save the latest record of deactivated plugins.
132 *
133 * @param array $deactivated_plugins Plugins to deactivate.
134 */
135function wpcomsh_cli_save_deactivated_plugins_record( $deactivated_plugins ) {
136    if ( empty( $deactivated_plugins ) ) {
137        delete_option( WPCOMSH_CLI_OPTION_DEACTIVATED_USER_PLUGINS );
138        return;
139    }
140
141    $updated = update_option( WPCOMSH_CLI_OPTION_DEACTIVATED_USER_PLUGINS, $deactivated_plugins, false /* don't autoload */ );
142    if (
143        false === $updated &&
144        // Make sure the update didn't fail because the option is already set to the desired value.
145        get_option( WPCOMSH_CLI_OPTION_DEACTIVATED_USER_PLUGINS ) !== $deactivated_plugins
146    ) {
147        WP_CLI::warning( 'Failed to update deactivated plugins list.' );
148    }
149}
150
151/**
152 * Removes expired deactivations from the deactivation record.
153 */
154function wpcomsh_cli_remove_expired_from_deactivation_record() {
155    $deactivated_plugins             = get_option( WPCOMSH_CLI_OPTION_DEACTIVATED_USER_PLUGINS, array() );
156    $deactivated_plugins_to_remember = array();
157    $current_time                    = time();
158
159    foreach ( $deactivated_plugins as $plugin_name => $timestamp ) {
160        if ( ( $current_time - $timestamp ) < WPCOMSH_CLI_PLUGIN_REACTIVATION_MAX_AGE ) {
161            $deactivated_plugins_to_remember[ $plugin_name ] = $timestamp;
162        }
163    }
164
165    wpcomsh_cli_save_deactivated_plugins_record( $deactivated_plugins_to_remember );
166}
167
168/**
169 * Keeps a single event scheduled to clean up the deactivated user plugin record.
170 *
171 * @return boolean Whether the scheduling update succeeded.
172 */
173function wpcomsh_cli_reschedule_deactivated_list_cleanup() {
174    static $rescheduled_cleanup = false;
175
176    // Avoid unnecessarily rescheduling multiple times within the same CLI command.
177    if ( ! $rescheduled_cleanup ) {
178        if (
179            false !== wp_next_scheduled( WPCOMSH_CLI_DEACTIVATED_PLUGIN_RECORD_CLEANUP_JOB ) &&
180            false === wp_unschedule_hook( WPCOMSH_CLI_DEACTIVATED_PLUGIN_RECORD_CLEANUP_JOB )
181        ) {
182            // Avoid scheduling cleanup if we can't unschedule existing cleanup because scheduled jobs could accumulate.
183            return false;
184        }
185
186        if ( false === get_option( WPCOMSH_CLI_OPTION_DEACTIVATED_USER_PLUGINS ) ) {
187            // No need to clean up a nonexistent option.
188            return true;
189        }
190
191        $rescheduled_cleanup = wp_schedule_single_event(
192            // Pad scheduled time to give everything time to expire.
193            time() + WPCOMSH_CLI_PLUGIN_REACTIVATION_MAX_AGE + 15 * MINUTE_IN_SECONDS,
194            WPCOMSH_CLI_DEACTIVATED_PLUGIN_RECORD_CLEANUP_JOB
195        );
196    }
197
198    return $rescheduled_cleanup;
199}
200
201/**
202 * Action hook for updating the deactivated plugin record when a plugin is deactivated.
203 *
204 * This allows us to maintain the deactivated plugin record in response to both
205 * the `wp plugin deactivate` and `wp wpcomsh deactivate-user-plugins` commands.
206 *
207 * @param string $file Plugin file.
208 */
209function wpcomsh_cli_remember_plugin_deactivation( $file ) {
210    $deactivated_plugins                 = get_option( WPCOMSH_CLI_OPTION_DEACTIVATED_USER_PLUGINS );
211    $plugin_name                         = WP_CLI\Utils\get_plugin_name( $file );
212    $deactivated_plugins[ $plugin_name ] = time();
213    wpcomsh_cli_save_deactivated_plugins_record( $deactivated_plugins );
214    wpcomsh_cli_reschedule_deactivated_list_cleanup();
215}
216
217/**
218 * Action hook for pruning the deactivated plugin record when a plugin is activated.
219 *
220 * This allows us to neatly maintain the deactivated plugin record in response to both
221 * the `wp plugin activate` and `wp wpcomsh reactivate-user-plugins` commands.
222 *
223 * @param string $file Plugin file.
224 */
225function wpcomsh_cli_forget_plugin_deactivation( $file ) {
226    $deactivated_plugins = get_option( WPCOMSH_CLI_OPTION_DEACTIVATED_USER_PLUGINS );
227    $plugin_name         = WP_CLI\Utils\get_plugin_name( $file );
228    unset( $deactivated_plugins[ $plugin_name ] );
229    wpcomsh_cli_save_deactivated_plugins_record( $deactivated_plugins );
230}
231
232// phpcs:disable Squiz.Commenting.FunctionComment.MissingParamTag
233if ( class_exists( 'WP_CLI_Command' ) ) {
234    /**
235     * WPCOMSH-specific CLI commands
236     */
237    class WPCOMSH_CLI_Commands extends WP_CLI_Command {
238        /**
239         * Bulk deactivate user installed plugins
240         *
241         * Deactivate all user installed plugins except for important ones for Atomic.
242         *
243         * ## OPTIONS
244         *
245         * [--interactive]
246         * : Ask for each active plugin whether to deactivate
247         *
248         * @subcommand deactivate-user-plugins
249         */
250        public function deactivate_user_installed_plugins( $args, $assoc_args = array() ) {
251            $active_plugins = wpcomsh_cli_get_plugins_with_status( 'active' );
252            if ( false === $active_plugins ) {
253                WP_CLI::log( 'Failed to list active plugins.' );
254            }
255
256            $plugins_to_skip = WPCOMSH_CLI_DONT_DEACTIVATE_PLUGINS;
257            if ( wpcom_site_has_feature( WPCOM_Features::ECOMMERCE_MANAGED_PLUGINS ) ) {
258                // This site has access to the e-commerce plugin bundle, so we don't want to deactivate them.
259                $plugins_to_skip = array_unique( array_merge( $plugins_to_skip, WPCOMSH_CLI_ECOMMERCE_PLAN_PLUGINS ) );
260            }
261
262            foreach ( array_intersect( $active_plugins, $plugins_to_skip ) as $skipped ) {
263                WP_CLI::log( WP_CLI::colorize( "  %b- skipping '$skipped'%n" ) );
264            }
265
266            $plugins_to_deactivate = array_diff( $active_plugins, $plugins_to_skip );
267            if ( empty( $plugins_to_deactivate ) ) {
268                WP_CLI::warning( 'No active user-installed plugins found.' );
269                return;
270            }
271
272            $interactive      = WP_CLI\Utils\get_flag_value( $assoc_args, 'interactive', false );
273            $green_check_mark = WP_CLI::colorize( "%G\xE2\x9C\x94%n" );
274            $red_x            = WP_CLI::colorize( '%Rx%n' );
275            foreach ( $plugins_to_deactivate as $plugin ) {
276                $deactivate = true;
277                if ( $interactive ) {
278                    $deactivate = wpcomsh_cli_confirm( 'Deactivate plugin "' . $plugin . '"?' );
279                }
280
281                if ( $deactivate ) {
282                    // Deactivate and print success/failure
283                    $result = WP_CLI::runcommand(
284                        "--skip-plugins --skip-themes plugin deactivate $plugin",
285                        array(
286                            'launch'     => false,
287                            'return'     => 'all',
288                            'exit_error' => false,
289                        )
290                    );
291                    if ( 0 === $result->return_code ) {
292                        WP_CLI::log( "  $green_check_mark deactivated '$plugin'" );
293                    } else {
294                        WP_CLI::log( "  $red_x failed to deactivate '$plugin'" );
295                        if ( ! empty( $result->stderr ) ) {
296                            WP_CLI::log( $result->stderr );
297                        }
298                    }
299                }
300            }
301        }
302
303        /**
304         * Bulk re-activate user installed plugins.
305         *
306         * If previously user installed plugins had been deactivated, this re-activates these plugins.
307         *
308         * ## OPTIONS
309         *
310         * [--interactive]
311         * : Ask for each previously deactivated plugin whether to activate.
312         *
313         * @subcommand reactivate-user-plugins
314         */
315        public function reactivate_user_installed_plugins( $args, $assoc_args = array() ) {
316            // Clean up before getting the deactivation list so there are only current entries.
317            wpcomsh_cli_remove_expired_from_deactivation_record();
318
319            $inactive_plugins = wpcomsh_cli_get_plugins_with_status( 'inactive' );
320            if ( false === $inactive_plugins ) {
321                WP_CLI::error( 'Failed to list inactive plugins for reactivation.' );
322                return;
323            }
324
325            $deactivation_records = get_option( WPCOMSH_CLI_OPTION_DEACTIVATED_USER_PLUGINS );
326            if ( false === $deactivation_records ) {
327                WP_CLI::warning( "Can't find any previously deactivated plugins to activate." );
328                return;
329            }
330
331            // TODO: Should we reactivate these in the reverse order that they were deactivated?
332            // Only try to reactivate plugins that exist and are inactive.
333            $plugins_to_reactivate = array_keys( $deactivation_records );
334            $plugins_to_reactivate = array_intersect( $plugins_to_reactivate, $inactive_plugins );
335
336            if ( empty( $plugins_to_reactivate ) ) {
337                WP_CLI::warning( "Can't find any previously deactivated plugins to activate." );
338                return;
339            }
340
341            $interactive = WP_CLI\Utils\get_flag_value( $assoc_args, 'interactive', false );
342            if ( ! $interactive ) {
343                // Since we're not confirming one-by-one, we'll confirm once for all.
344                WP_CLI::log( 'The following will be reactivated:' );
345                WP_CLI::log( '  - ' . implode( "\n  - ", $plugins_to_reactivate ) );
346                if ( ! wpcomsh_cli_confirm( 'Do you wish to proceed?' ) ) {
347                    return;
348                }
349            }
350
351            $green_check_mark = WP_CLI::colorize( "%G\xE2\x9C\x94%n" );
352            $red_x            = WP_CLI::colorize( '%Rx%n' );
353            foreach ( $plugins_to_reactivate as $plugin ) {
354                $reactivate = true;
355                if ( $interactive ) {
356                    $reactivate = wpcomsh_cli_confirm( 'Reactivate plugin "' . $plugin . '"?' );
357                }
358
359                if ( $reactivate ) {
360                    $result = WP_CLI::runcommand(
361                        "--skip-plugins --skip-themes plugin activate $plugin",
362                        array(
363                            'launch'     => false,
364                            'return'     => 'all',
365                            'exit_error' => false,
366                        )
367                    );
368                    if ( 0 === $result->return_code ) {
369                        WP_CLI::log( "  $green_check_mark activated '$plugin'" );
370                    } else {
371                        WP_CLI::log( "  $red_x failed to activate '$plugin'" );
372                        if ( ! empty( $result->stderr ) ) {
373                            WP_CLI::log( $result->stderr );
374                        }
375                    }
376                }
377            }
378        }
379
380        /**
381         * Fire the update_option_home action for domain change.
382         *
383         * This is necessary for some plugins such as Yoast that looks for this action when a domain is updated,
384         * and since the Atomic platform uses direct SQL queries to update the URL when it's changed in wpcom,
385         * this action never fires.
386         *
387         * ## OPTIONS
388         *
389         * [--old_url=<old_url>]
390         * : The URL that the domain was changed from
391         *
392         * [--new_url=<new_url>]
393         * : The URL that the domain was changed to
394         *
395         * @subcommand domain-name-changed
396         */
397        public function domain_name_changed( $args, $assoc_args = array() ) {
398            $old_domain = WP_CLI\Utils\get_flag_value( $assoc_args, 'old_url', false );
399            if ( false === $old_domain ) {
400                WP_CLI::error( 'Missing required --old_url=url value.' );
401            }
402
403            $new_domain = WP_CLI\Utils\get_flag_value( $assoc_args, 'new_url', false );
404            if ( false === $new_domain ) {
405                WP_CLI::error( 'Missing required --new_url=url value.' );
406            }
407
408            // Bail if we're getting a value that does not match reality of what's current.
409            if ( get_home_url() !== $new_domain ) {
410                WP_CLI::warning( 'Did not send action. New domain does not match current get_home_url value.' );
411                return;
412            }
413
414            if ( ! defined( 'WP_HOME' ) || WP_HOME !== $new_domain ) {
415                WP_CLI::warning( 'Did not send action. New domain does not match current WP_HOME value.' );
416                return;
417            }
418
419            do_action( 'update_option_home', $old_domain, $new_domain );
420            WP_CLI::success( 'Sent the update_option_home action successfully.' );
421        }
422
423        /**
424         * This is a post transfer command that is called after a site is transferred.
425         *
426         * This is necessary for some plugins that need to perform certain actions after
427         * a site is transferred, such as WooCommerce Payments that needs to clear its cache.
428         *
429         * Note: This command should only be executed from WPCOM as part of a transfer.
430         *
431         * @subcommand post-transfer
432         */
433        public function post_transfer( $args, $assoc_args = array() ) {
434            do_action( 'wpcomsh_woa_post_transfer', $args, $assoc_args );
435
436            WP_CLI::success( 'Post transfer completed successfully.' );
437        }
438
439        /**
440         * This is a post reset command that is called after a site is reset.
441         *
442         * This is necessary for some plugins that need to perform certain actions after
443         * a site is reset, such as WooCommerce Payments that needs to clear its cache.
444         *
445         * Note: This command should only be executed from WPCOM as part of a transfer.
446         *
447         * @subcommand post-reset
448         */
449        public function post_reset( $args, $assoc_args = array() ) {
450            do_action( 'wpcomsh_woa_post_reset', $args, $assoc_args );
451
452            WP_CLI::success( 'Post reset completed successfully.' );
453        }
454
455        /**
456         * This is a post clone command that is called after a site is cloned.
457         *
458         * This is necessary for some plugins that need to perform certain actions after
459         * a site is cloned, such as WooCommerce Payments that needs to clear its cache.
460         *
461         * Note: This command should only be executed from WPCOM as part of a transfer.
462         *
463         * @subcommand post-clone
464         */
465        public function post_clone( $args, $assoc_args = array() ) {
466            do_action( 'wpcomsh_woa_post_clone', $args, $assoc_args );
467
468            WP_CLI::success( 'Post clone completed successfully.' );
469        }
470
471        /**
472         * Proxies wp language plugin install --all using the active site language.
473         *
474         * After switching the site language, language packs for plugins are not automatically downloaded and the user
475         * has to manually check for and install updates, this command installs language packs for all plugins,
476         * using the active site language.
477         *
478         * @subcommand install-plugin-language-packs
479         */
480        public function install_plugin_language_packs() {
481            /*
482             * Query the database directly as we previously hooked into pre_option_WPLANG to always return en_US,
483             * but now we need the actual site language to figure out what language packs to install.
484             */
485            global $wpdb;
486            // phpcs:ignore WordPress.DB.DirectDatabaseQuery
487            $lang = $wpdb->get_var( 'SELECT option_value FROM ' . $wpdb->options . " WHERE option_name = 'WPLANG'" );
488            if ( empty( $lang ) ) {
489                $lang = 'en_US';
490            }
491
492            $command = new Plugin_Language_Command();
493            $command->install(
494                array( $lang ),
495                array(
496                    'all' => true,
497                )
498            );
499        }
500
501        /**
502         * Retrieves an Atomic persistent data field.
503         *
504         * ## OPTIONS
505         *
506         * <name>
507         * : The name of the data field to retrieve
508         *
509         * [--format=<format>]
510         * : Render output in a particular format.
511         * ---
512         * default: list
513         * options:
514         *   - list
515         *   - json
516         * ---
517         *
518         * @subcommand persistent-data
519         */
520        public function persistent_data( $args, $assoc_args ) {
521            if ( empty( $args[0] ) ) {
522                WP_CLI::error( 'Missing required field name.' );
523            }
524
525            $name            = $args[0];
526            $persistent_data = new Atomic_Persistent_Data();
527
528            $output = json_decode( $persistent_data->{ $name } );
529            if ( null === $output ) {
530                $output = $persistent_data->{ $name };
531            }
532
533            if ( 'json' === $assoc_args['format'] ) {
534                $output = wp_json_encode( $output, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT );
535            }
536
537            WP_CLI::log( print_r( $output, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
538        }
539
540        /**
541         * Retrieves the WPCOM_PURCHASES field from Atomic Persistent Data.
542         *
543         * ## OPTIONS
544         *
545         * [--format=<format>]
546         * : Render output in a particular format.
547         * ---
548         * default: list
549         * options:
550         *   - list
551         *   - json
552         * ---
553         *
554         * @subcommand purchases
555         */
556        public function purchases( $args, $assoc_args ) {
557            WP_CLI::runcommand( 'wpcomsh persistent-data WPCOM_PURCHASES --format=' . $assoc_args['format'], array( 'launch' => false ) );
558        }
559
560        /**
561         * Apply terms and taxonomies from the current theme's annotation file.
562         *
563         * In the case of WooCommerce specific terms, they can only be applied
564         * after WooCommerce is installed, which might happen after a site's theme switch.
565         * So this is provided as a separate command which can be ran in a post-install job.
566         *
567         * @subcommand headstart-terms
568         */
569        public function headstart_terms( $args, $assoc_args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis
570            $results            = wpcomsh_apply_headstart_terms();
571            $missing_taxonomies = $results['missing_taxonomies'];
572            $output             = wp_json_encode( array( 'missing_taxonomies' => $missing_taxonomies ), JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT );
573            WP_CLI::log( $output );
574        }
575
576        /**
577         * Import a backup .zip file.
578         *
579         * ## OPTIONS
580         *
581         * [--source]
582         * : Source zip file path.
583         *
584         * [--dest]
585         * : destination file path to extract to. (required)
586         *
587         * [--skip-clean-up]
588         * : Skip cleaning up the temprary files. Defaults to false.
589         *
590         * [--skip-unpack]
591         * : Skip unpacking the zip file. Defaults to false.
592         *
593         * [--actions]
594         * : A comma-separated list of actions to perform. Defaults to all actions.
595         *
596         * [--dry-run]
597         * : Run the importer in dry run mode. Defaults to true.
598         *
599         * @subcommand backup-import
600         */
601        public function backup_import( $args, $assoc_args ) {
602            $source        = WP_CLI\Utils\get_flag_value( $assoc_args, 'source', '' );
603            $dest          = WP_CLI\Utils\get_flag_value( $assoc_args, 'dest' );
604            $skip_clean_up = WP_CLI\Utils\get_flag_value( $assoc_args, 'skip-clean-up', false );
605            $skip_unpack   = WP_CLI\Utils\get_flag_value( $assoc_args, 'skip-unpack', false );
606            $actions       = WP_CLI\Utils\get_flag_value( $assoc_args, 'actions', '' );
607            $dry_run       = WP_CLI\Utils\get_flag_value( $assoc_args, 'dry-run', true );
608
609            $skip_unpack = filter_var( $skip_unpack, FILTER_VALIDATE_BOOLEAN );
610
611            if ( ! $skip_unpack && empty( $source ) ) {
612                WP_CLI::error( 'Missing file path passed to --source' );
613            }
614
615            if ( empty( $dest ) ) {
616                WP_CLI::error( 'Missing file path passed to --dest' );
617            }
618
619            $options = array(
620                'skip_clean_up' => filter_var( $skip_clean_up, FILTER_VALIDATE_BOOLEAN ),
621                'skip_unpack'   => $skip_unpack,
622                'actions'       => $actions ? explode( ',', $actions ) : array(),
623                'dry_run'       => filter_var( $dry_run, FILTER_VALIDATE_BOOLEAN ),
624            );
625
626            $import_manager = new Imports\Backup_Import_Manager( $source, $dest, $options );
627            $ret            = $import_manager->import();
628
629            if ( is_wp_error( $ret ) ) {
630                WP_CLI::error( $ret->get_error_message() );
631            }
632
633            WP_CLI::success( 'Import completed successfully.' );
634        }
635
636        /**
637         * Manage user's global styles.
638         *
639         * ## OPTIONS
640         *
641         * <action>
642         * : The action you want to run, e.g.: list, update, remove.
643         *
644         * [--field=<field>]
645         * : The path of the data field to retrieve or remove.
646         *
647         * [--value=<value>]
648         * : The value of the data field you want to set.
649         *
650         * [--dry-run]
651         * : Enable dry run mode
652         *
653         * @subcommand global-styles
654         */
655        public function global_styles( $args, $assoc_args ) {
656            if ( empty( $args[0] ) ) {
657                WP_CLI::error( 'Missing the action.' );
658            }
659
660            $available_actions = array( 'list', 'update', 'remove' );
661            $action            = $args[0];
662            if ( ! in_array( $action, $available_actions, true ) ) {
663                WP_CLI::error( 'The action is not supported yet' );
664            }
665
666            /**
667             * Get the global styles
668             */
669            $active_global_styles_id = WP_Theme_JSON_Resolver::get_user_global_styles_post_id();
670            $request                 = new \WP_REST_Request( 'GET', "/wp/v2/global-styles/$active_global_styles_id" );
671            $request->set_query_params(
672                array(
673                    'context' => 'edit',
674                    'id'      => $active_global_styles_id,
675                )
676            );
677
678            $global_styles_controller = new WP_REST_Global_Styles_Controller();
679            $response                 = $global_styles_controller->get_item( $request );
680            if ( $response->is_error() ) {
681                WP_CLI::error( $response->as_error() );
682            }
683
684            $global_styles = $response->get_data();
685            $field         = $assoc_args['field'] ?? '';
686            $field_path    = ! empty( $field ) ? explode( '.', $field ) : array();
687            if ( $action === 'list' ) {
688                $global_styles = $response->get_data();
689                $global_styles = ! empty( $field_path ) ? _wp_array_get( $global_styles, $field_path ) : $global_styles;
690                WP_CLI::log( wp_json_encode( $global_styles, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT ) );
691                return;
692            }
693
694            $dry_run = isset( $assoc_args['dry-run'] ) ? filter_var( $assoc_args['dry-run'], FILTER_VALIDATE_BOOLEAN ) : false;
695            if ( $action === 'update' ) {
696                if ( empty( $field_path ) ) {
697                    WP_CLI::error( 'Missing the data field you want to remove, e.g.: settings.typography.fontFamilies.theme' );
698                }
699
700                if ( ! isset( $assoc_args['value'] ) ) {
701                    WP_CLI::error( 'Missing the value you want to set.' );
702                }
703
704                $value               = json_decode( $assoc_args['value'], true );
705                $json_decoding_error = json_last_error();
706                if ( JSON_ERROR_NONE !== $json_decoding_error ) {
707                    WP_CLI::error( 'The provided value is invalid.' );
708                }
709
710                _wp_array_set( $global_styles, $field_path, $value );
711
712                if ( $dry_run ) {
713                    WP_CLI::log( wp_json_encode( $global_styles, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT ) );
714                } else {
715                    $request = new \WP_REST_Request( 'POST', "/wp/v2/global-styles/$active_global_styles_id" );
716                    $request->set_query_params( $global_styles );
717                    $response = $global_styles_controller->update_item( $request );
718                    if ( $response->is_error() ) {
719                        WP_CLI::error( $response->as_error() );
720                    }
721
722                    WP_CLI::log( wp_json_encode( $response->get_data(), JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT ) );
723                }
724
725                WP_CLI::success( "Update the data field `$field` successfully" );
726            }
727
728            if ( $action === 'remove' ) {
729                if ( empty( $field_path ) ) {
730                    WP_CLI::error( 'Missing the data field you want to remove, e.g.: settings.typography.fontFamilies.theme' );
731                }
732
733                $length  = count( $field_path );
734                $current = &$global_styles;
735                for ( $i = 0; $i < $length - 1; ++$i ) {
736                    $path = $field_path[ $i ];
737                    if ( ! array_key_exists( $path, $current ) || ! is_array( $current[ $path ] ) ) {
738                        WP_CLI::error( "The data field `$field` doesn't exist" );
739                    }
740
741                    $current = &$current[ $path ];
742                }
743
744                unset( $current[ $field_path[ $i ] ] );
745
746                if ( $dry_run ) {
747                    WP_CLI::log( wp_json_encode( $global_styles, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT ) );
748                } else {
749                    $request = new \WP_REST_Request( 'POST', "/wp/v2/global-styles/$active_global_styles_id" );
750                    $request->set_query_params( $global_styles );
751                    $response = $global_styles_controller->update_item( $request );
752                    if ( $response->is_error() ) {
753                        WP_CLI::error( $response->as_error() );
754                    }
755
756                    WP_CLI::log( wp_json_encode( $response->get_data(), JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT ) );
757                }
758
759                WP_CLI::success( "Removing the data field `$field` successfully" );
760            }
761        }
762
763        /**
764         * List incompatible plugins on the site.
765         *
766         * ## OPTIONS
767         *
768         * <action>
769         * : The action you want to run. Only `list` supported at present.
770         * ---
771         * options:
772         *  - list
773         * ---
774         *
775         * [--field=<field>]
776         * : Prints the value of a single field for each incompatible plugin.
777         *
778         * [--fields=<fields>]
779         * : The fields to include in the output.
780         *
781         * [--format=<format>]
782         * : The output format to use.
783         * ---
784         * default: table
785         * options:
786         *  - table
787         *  - csv
788         *  - json
789         * ---
790         *
791         * [--status=<status>]
792         * : Only return incompatible plugins with a specific status.
793         * ---
794         * options:
795         *  - active
796         *  - inactive
797         *  - active-network
798         *  - must-use
799         *
800         * ## AVAILABLE FIELDS
801         *
802         * These fields will be displayed by default for each plugin:
803         *
804         * * name
805         * * status
806         * * version
807         *
808         * These fields are optionally available:
809         *
810         * * message
811         * * title
812         * * description
813         * * file
814         * * author
815         *
816         * @subcommand incompatible-plugins
817         */
818        public function incompatible_plugins( $args, $assoc_args ) {
819            if ( empty( $args[0] ) ) {
820                WP_CLI::error( 'No action specified.' );
821            }
822
823            $action = $args[0];
824
825            $supported_actions = array( 'list' );
826
827            if ( ! in_array( $action, $supported_actions, true ) ) {
828                WP_CLI::error( "Unsupported action: '{$action}'. Must be one of: " . implode( '|', $supported_actions ) );
829            }
830
831            $jetpack_plugin_compatibility = Jetpack_Plugin_Compatibility::get_instance();
832
833            $incompatible_plugins = $jetpack_plugin_compatibility->find_incompatible_plugins();
834
835            $status_to_filter = \WP_CLI\Utils\get_flag_value( $assoc_args, 'status' );
836            if ( ! empty( $status_to_filter ) ) {
837                $incompatible_plugins = array_filter(
838                    $incompatible_plugins,
839                    function ( $incompatible_plugin_details ) use ( $status_to_filter ) {
840                        return $status_to_filter === ( $incompatible_plugin_details['status'] ?? null );
841                    }
842                );
843            }
844
845            if ( empty( $incompatible_plugins ) ) {
846                WP_CLI::success( 'No incompatible plugins found.' );
847                return;
848            }
849
850            $refined_plugin_list = array();
851
852            foreach ( $incompatible_plugins as $plugin_filename => $plugin_details ) {
853                $refined_plugin_list[] = array(
854                    'name'        => \WP_CLI\Utils\get_plugin_name( $plugin_filename ),
855                    'status'      => $plugin_details['status'],
856                    'version'     => $plugin_details['details']['Version'] ?? '',
857                    'message'     => $plugin_details['message'],
858                    'title'       => $plugin_details['details']['Name'] ?? '',
859                    'description' => $plugin_details['details']['Description'] ?? '',
860                    'file'        => $plugin_filename,
861                    'author'      => $plugin_details['details']['Author'] ?? '',
862                );
863            }
864
865            $formatter = new \WP_CLI\Formatter( $assoc_args, array( 'name', 'status', 'version' ), 'plugin' );
866
867            $formatter->display_items( $refined_plugin_list );
868        }
869
870        /**
871         * Patch js_composer plugin to work with PHP 8.1.
872         *
873         * ## OPTIONS
874         *
875         * <plugin>
876         * : The plugin to patch.
877         *
878         * @subcommand php81-plugin-patch
879         */
880        public function php_81_plugin_patch( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
881            if ( 'js_composer' !== $args[0] ) {
882                WP_CLI::error( 'Wrong plugin to patch.' );
883            }
884
885            $plugins = get_plugins();
886            $folder  = 'js_composer/js_composer.php';
887
888            if ( ! isset( $plugins[ $folder ] ) ) {
889                WP_CLI::error( 'js_composer plugin is not installed.' );
890            }
891
892            $file = WP_PLUGIN_DIR . '/js_composer/include/classes/editors/class-vc-frontend-editor.php';
893
894            if ( ! file_exists( $file ) ) {
895                WP_CLI::error( 'File not found: ' . $file );
896            }
897
898            $search        = '$host = isset( $s[\'HTTP_X_FORWARDED_HOST\'] ) ? $s[\'HTTP_X_FORWARDED_HOST\'] : isset( $s[\'HTTP_HOST\'] ) ? $s[\'HTTP_HOST\'] : $s[\'SERVER_NAME\'];';
899            $substitution  = "// The following line has been patched by wpcomsh to let this plugin work with PHP 8.1.\n";
900            $substitution .= '        $host = isset( $s[\'HTTP_X_FORWARDED_HOST\'] ) ? $s[\'HTTP_X_FORWARDED_HOST\'] : ( isset($s[\'HTTP_HOST\'] ) ? $s[\'HTTP_HOST\'] : $s[\'SERVER_NAME\'] );';
901
902            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
903            $file_content = file_get_contents( $file );
904
905            if ( false === $file_content ) {
906                WP_CLI::error( 'File not found: ' . $file );
907            }
908
909            $count        = 0;
910            $file_content = str_replace( $search, $substitution, $file_content, $count );
911
912            if ( ! $count ) {
913                WP_CLI::error( 'String not found on ' . $file );
914            }
915
916            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
917            if ( ! file_put_contents( $file, $file_content ) ) {
918                WP_CLI::error( 'Failed to write to ' . $file );
919            }
920
921            WP_CLI::success( 'Success' );
922        }
923
924        /**
925         * Patch mega_main_menu plugin to work with PHP 8.3+.
926         *
927         * The `mm_options_generator()` function declares `static $theme_option_file`
928         * in both the `file` and `background_image` cases of the same switch. PHP 8.3
929         * turned a repeated `static` declaration of the same variable in one function
930         * scope into a "Duplicate declaration of static variable" fatal error. This
931         * renames the copy in the `background_image` case so only a single declaration
932         * of `$theme_option_file` remains. Both cases merely enqueue the media uploader
933         * scripts, and `wp_enqueue_*` is idempotent, so behavior is unchanged.
934         *
935         * ## OPTIONS
936         *
937         * <plugin>
938         * : The plugin to patch.
939         *
940         * @subcommand php83-plugin-patch
941         */
942        public function php_83_plugin_patch( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
943            if ( 'mega_main_menu' !== $args[0] ) {
944                WP_CLI::error( 'Wrong plugin to patch.' );
945            }
946
947            $plugins = get_plugins();
948            $folder  = 'mega_main_menu/mega_main_menu.php';
949
950            if ( ! isset( $plugins[ $folder ] ) ) {
951                WP_CLI::error( 'mega_main_menu plugin is not installed.' );
952            }
953
954            // Don't patch if an update is pending: the new version may already fix this,
955            // and an update would overwrite the patched file anyway. Refresh the
956            // transient first so the decision is based on current data.
957            wp_update_plugins();
958            $update_plugins = get_site_transient( 'update_plugins' );
959
960            if ( isset( $update_plugins->response[ $folder ] ) ) {
961                $new_version = $update_plugins->response[ $folder ]->new_version ?? 'unknown';
962                WP_CLI::error( "An update to mega_main_menu $new_version is available; update the plugin instead of patching." );
963            }
964
965            $file = WP_PLUGIN_DIR . '/mega_main_menu/framework/options_generator.php';
966
967            if ( ! file_exists( $file ) ) {
968                WP_CLI::error( 'File not found: ' . $file );
969            }
970
971            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
972            $file_content = file_get_contents( $file );
973
974            if ( false === $file_content ) {
975                WP_CLI::error( 'File not found: ' . $file );
976            }
977
978            // Already patched: re-running is a safe no-op.
979            if ( false !== strpos( $file_content, '$theme_option_file_background' ) ) {
980                WP_CLI::success( 'Already patched.' );
981                return;
982            }
983
984            // The duplicate `static $theme_option_file` lives in the `background_image`
985            // case. Split the file there so we only rename that copy and leave the
986            // original declaration in the `file` case untouched.
987            $marker     = "case 'background_image':";
988            $marker_pos = strpos( $file_content, $marker );
989
990            if ( false === $marker_pos ) {
991                WP_CLI::error( 'Patch target not found in ' . $file );
992            }
993
994            $before = substr( $file_content, 0, $marker_pos );
995            $after  = substr( $file_content, $marker_pos );
996
997            // Within $after only the `background_image` case references
998            // `$theme_option_file` (the `file` case sits in $before, `gradient` uses a
999            // different variable), so renaming every occurrence here is surgical.
1000            $count = 0;
1001            $after = str_replace( '$theme_option_file', '$theme_option_file_background', $after, $count );
1002
1003            if ( ! $count ) {
1004                WP_CLI::error( 'String not found on ' . $file );
1005            }
1006
1007            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
1008            if ( ! file_put_contents( $file, $before . $after ) ) {
1009                WP_CLI::error( 'Failed to write to ' . $file );
1010            }
1011
1012            WP_CLI::success( 'Success' );
1013        }
1014
1015        /**
1016         * Enable or disable fatal error emails.
1017         *
1018         * ## OPTIONS
1019         *
1020         * <command>
1021         * : The subcommand
1022         * ---
1023         * options:
1024         *  - get
1025         *  - set
1026         * ---
1027         *
1028         * [--value=<value>]
1029         * : The value (when setting)
1030         * ---
1031         * default: 1
1032         * options:
1033         *  - 0
1034         *  - 1
1035         * ---
1036         *
1037         * @subcommand disable-fatal-error-emails
1038         */
1039        public function fatal_error_emails_disable( $args, $assoc_args ) {
1040            $command = $args[0];
1041            $value   = (bool) $assoc_args['value'];
1042
1043            switch ( $command ) {
1044                case 'get':
1045                    $option = get_option( 'wpcomsh_disable_fatal_error_emails', false );
1046                    WP_CLI::log( $option ? 'true' : 'false' );
1047                    break;
1048                case 'set':
1049                    update_option( 'wpcomsh_disable_fatal_error_emails', $value );
1050                    WP_CLI::success( 'Success' );
1051                    break;
1052                default:
1053                    WP_CLI::error( 'Invalid command' );
1054            }
1055        }
1056
1057        /**
1058         * Check if the site is healthy after activating a plugin.
1059         * This is a helper function for the plugin-dance command.
1060         *
1061         * @return bool
1062         */
1063        private function do_plugin_dance_health_check() {
1064            $result = WP_CLI::runcommand(
1065                '--skip-themes= --skip-plugins= wpcomsh plugin-dance-health-check', // pass empty values to skip-themes and skip-plugins.
1066                array(
1067                    'return'     => true,
1068                    'launch'     => true, // must run in a new process to avoid false positives.
1069                    'exit_error' => false,
1070                )
1071            );
1072
1073            return (bool) strpos( $result, 'Healthy' );
1074        }
1075
1076        /**
1077         * Tries disabling all plugins & enabling them one by one to find the plugin causing the issue.
1078         * Outputs a list of plugins that are disabled.
1079         *
1080         * ## OPTIONS
1081         *
1082         * [--strategy=<strategy>]
1083         * : The strategy to use to find the breaking plugin. Defaults to 'one-by-one'.
1084         * ---
1085         * default: one-by-one
1086         * options:
1087         *  - one-by-one
1088         *  - disable-all
1089         *
1090         * @subcommand plugin-dance
1091         */
1092        public function plugin_dance( $args, $assoc_args ) {
1093            $healthy = $this->do_plugin_dance_health_check();
1094            if ( $healthy ) {
1095                WP_CLI::success( '✔ Site health check passed before doing anything.' );
1096                return;
1097            }
1098
1099            $plugins = WP_CLI::runcommand(
1100                '--skip-plugins --skip-themes plugin list --status=active --format=json',
1101                array(
1102                    'launch' => false,
1103                    'return' => true,
1104                )
1105            );
1106
1107            $plugins = json_decode( $plugins, true );
1108
1109            // Filter out plugins we won't be touching. These won't be deactivated by deactivate-user-plugins.
1110            $plugins_to_reactivate = array_filter(
1111                $plugins,
1112                function ( $plugin ) {
1113                    $plugin_name = $plugin['name'];
1114                    if ( in_array( $plugin_name, WPCOMSH_CLI_DONT_DEACTIVATE_PLUGINS, true ) || in_array( $plugin_name, WPCOMSH_CLI_ECOMMERCE_PLAN_PLUGINS, true ) ) {
1115                        WP_CLI::log( sprintf( 'ℹ️ Skipping %s.', $plugin_name ) );
1116                        return false;
1117                    }
1118
1119                    return true;
1120                }
1121            );
1122
1123            $breaking_plugins = array();
1124
1125            if ( 'one-by-one' === $assoc_args['strategy'] ) {
1126                while ( ! $healthy ) {
1127                    $plugin_to_deactivate = array_pop( $plugins_to_reactivate );
1128                    if ( empty( $plugin_to_deactivate ) ) {
1129                        WP_CLI::error( '❌ Site health check failed after testing all plugins one by one.' );
1130                        return;
1131                    }
1132
1133                    WP_CLI::runcommand(
1134                        sprintf( '--skip-themes plugin deactivate %s', $plugin_to_deactivate['name'] ),
1135                        array(
1136                            'launch' => false,
1137                            'return' => true,
1138                        )
1139                    );
1140
1141                    $healthy = $this->do_plugin_dance_health_check();
1142
1143                    if ( ! $healthy ) {
1144                        WP_CLI::log( sprintf( 'ℹ️ Site health check still failed after deactivating: %s. Reactivating.', $plugin_to_deactivate['name'] ) );
1145                        $result = WP_CLI::runcommand(
1146                            sprintf( '--skip-themes plugin activate %s', $plugin_to_deactivate['name'] ),
1147                            array(
1148                                'launch'     => true,  // needed for exit_error => false.
1149                                'return'     => true,
1150                                'exit_error' => false,
1151                            )
1152                        );
1153
1154                        if ( empty( $result ) ) {
1155                            WP_CLI::log( sprintf( '❌ Plugin did not like being activated: %s (probably broken).', $plugin_to_deactivate['name'] ) );
1156                            $breaking_plugins[] = array(
1157                                'name'    => $plugin_to_deactivate['name'],
1158                                'version' => $plugin_to_deactivate['version'],
1159                            );
1160                        }
1161                    } else {
1162                        WP_CLI::log( sprintf( '✔ Site health check passed after deactivating: %s.', $plugin_to_deactivate['name'] ) );
1163                        $breaking_plugins[] = array(
1164                            'name'    => $plugin_to_deactivate['name'],
1165                            'version' => $plugin_to_deactivate['version'],
1166                        );
1167                    }
1168                }
1169            } elseif ( 'disable-all' === $assoc_args['strategy'] ) {
1170                WP_CLI::log( 'ℹ️ Deactivating all user plugins.' );
1171
1172                // deactivate all active plugins.
1173                WP_CLI::runcommand(
1174                    '--skip-plugins --skip-themes wpcomsh deactivate-user-plugins',
1175                    array(
1176                        'launch' => false,
1177                    )
1178                );
1179
1180                if ( ! $this->do_plugin_dance_health_check() ) {
1181                    WP_CLI::log( '❌ Site health check failed after deactivating all plugins. Something non-plugin related is causing the issue. Trying to reactivate all plugins.' );
1182
1183                    WP_CLI::runcommand(
1184                        '--skip-themes --skip-plugins wpcomsh reactivate-user-plugins',
1185                        array()
1186                    );
1187                    return;
1188                }
1189
1190                WP_CLI::log( sprintf( 'ℹ️ %d plugins will be reactivated one by one to find the breaking plugin.', count( $plugins_to_reactivate ) ) );
1191
1192                // loop through each active plugin and activate one by one.
1193                foreach ( $plugins_to_reactivate as $plugin ) {
1194                    $result = WP_CLI::runcommand(
1195                        sprintf( '--skip-themes plugin activate %s', $plugin['name'] ),
1196                        array(
1197                            'launch'     => true, // needed for exit_error => false.
1198                            'return'     => true,
1199                            'exit_error' => false,
1200                        )
1201                    );
1202                    if ( empty( $result ) ) {
1203                        WP_CLI::log( sprintf( '❌ Plugin did not like being activated: %s (probably broken).', $plugin['name'] ) );
1204                        $breaking_plugins[] = array(
1205                            'name'    => $plugin['name'],
1206                            'version' => $plugin['version'],
1207                        );
1208                        continue;
1209                    }
1210
1211                    if ( ! $this->do_plugin_dance_health_check() ) {
1212                        // deactivate the breaking plugin
1213                        WP_CLI::runcommand(
1214                            sprintf( '--skip-themes plugin deactivate %s', $plugin['name'] ),
1215                            array(
1216                                'launch' => false,
1217                                'return' => true,
1218                            )
1219                        );
1220                        WP_CLI::log( sprintf( '❌ Plugin activated, site health check failed and deactivated: %s.', $plugin['name'] ) );
1221
1222                        $breaking_plugins[] = array(
1223                            'name'    => $plugin['name'],
1224                            'version' => $plugin['version'],
1225                        );
1226                    } else {
1227                        WP_CLI::log( sprintf( '✔ Plugin activated and site health check passed: %s.', $plugin['name'] ) );
1228                    }
1229                }
1230
1231                if ( empty( $breaking_plugins ) ) {
1232                    WP_CLI::success( 'All plugins passed the site health check.' );
1233                }
1234            }
1235
1236            if ( ! empty( $breaking_plugins ) ) {
1237                $formatter = new \WP_CLI\Formatter(
1238                    $assoc_args,
1239                    array( 'name', 'version' )
1240                );
1241                $formatter->display_items( $breaking_plugins );
1242            }
1243        }
1244
1245        /**
1246         * This just outputs healthy. If there are errors this doesn't get outputted at all
1247         *
1248         * @subcommand plugin-dance-health-check
1249         */
1250        public function plugin_dance_health_check( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1251            WP_CLI::success( 'Healthy' );
1252        }
1253
1254        /**
1255         * Runs comprehensive site diagnostics including Jetpack status, admin users, plugins, purchases, and PHP errors
1256         *
1257         * @subcommand diag
1258         */
1259        public function diagnostic( $args, $assoc_args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1260            WP_CLI::log( WP_CLI::colorize( '%B=== SITE DIAGNOSTICS ===%n' ) );
1261            WP_CLI::log( '' );
1262
1263            // 1. Jetpack Status
1264            WP_CLI::log( WP_CLI::colorize( '%Y--- Jetpack Status ---%n' ) );
1265            $jetpack_result = WP_CLI::runcommand(
1266                'jetpack status full',
1267                array(
1268                    'launch'     => false,
1269                    'return'     => 'all',
1270                    'exit_error' => false,
1271                )
1272            );
1273
1274            if ( 0 === $jetpack_result->return_code ) {
1275                WP_CLI::log( $jetpack_result->stdout );
1276            } else {
1277                WP_CLI::log( WP_CLI::colorize( '%RJetpack status command failed:%n' ) );
1278                WP_CLI::log( $jetpack_result->stderr );
1279            }
1280
1281            WP_CLI::log( '' );
1282
1283            // 2. Admin Users
1284            WP_CLI::log( WP_CLI::colorize( '%Y--- Administrator Users ---%n' ) );
1285            $admin_users_result = WP_CLI::runcommand(
1286                'user list --role=administrator',
1287                array(
1288                    'launch'     => false,
1289                    'return'     => 'all',
1290                    'exit_error' => false,
1291                )
1292            );
1293
1294            if ( 0 === $admin_users_result->return_code ) {
1295                WP_CLI::log( $admin_users_result->stdout );
1296            } else {
1297                WP_CLI::log( WP_CLI::colorize( '%RAdmin users command failed:%n' ) );
1298                WP_CLI::log( $admin_users_result->stderr );
1299            }
1300
1301            WP_CLI::log( '' );
1302
1303            // 3. Plugin Status
1304            WP_CLI::log( WP_CLI::colorize( '%Y--- Plugin Status ---%n' ) );
1305            $plugin_status_result = WP_CLI::runcommand(
1306                'plugin status',
1307                array(
1308                    'launch'     => false,
1309                    'return'     => 'all',
1310                    'exit_error' => false,
1311                )
1312            );
1313
1314            if ( 0 === $plugin_status_result->return_code ) {
1315                WP_CLI::log( $plugin_status_result->stdout );
1316            } else {
1317                WP_CLI::log( WP_CLI::colorize( '%RPlugin status command failed:%n' ) );
1318                WP_CLI::log( $plugin_status_result->stderr );
1319            }
1320
1321            WP_CLI::log( '' );
1322
1323            // 4. Theme Status
1324            WP_CLI::log( WP_CLI::colorize( '%Y--- Theme Status ---%n' ) );
1325            $theme_status_result = WP_CLI::runcommand(
1326                'theme status',
1327                array(
1328                    'launch'     => false,
1329                    'return'     => 'all',
1330                    'exit_error' => false,
1331                )
1332            );
1333
1334            if ( 0 === $theme_status_result->return_code ) {
1335                WP_CLI::log( $theme_status_result->stdout );
1336            } else {
1337                WP_CLI::log( WP_CLI::colorize( '%RTheme status command failed:%n' ) );
1338                WP_CLI::log( $theme_status_result->stderr );
1339            }
1340
1341            WP_CLI::log( '' );
1342
1343            // 5. WPCOMSH Purchases (formatted as table)
1344            WP_CLI::log( WP_CLI::colorize( '%Y--- Site Purchases ---%n' ) );
1345            $purchases_result = WP_CLI::runcommand(
1346                'wpcomsh purchases --format=json',
1347                array(
1348                    'launch'     => false,
1349                    'return'     => 'all',
1350                    'exit_error' => false,
1351                )
1352            );
1353
1354            if ( 0 === $purchases_result->return_code ) {
1355                $purchases_data = json_decode( $purchases_result->stdout, true );
1356                if ( is_array( $purchases_data ) && ! empty( $purchases_data ) ) {
1357                    $formatted_purchases = array();
1358                    foreach ( $purchases_data as $purchase ) {
1359                        $formatted_purchases[] = array(
1360                            'product'    => $purchase['product_slug'] ?? 'N/A',
1361                            'type'       => $purchase['product_type'] ?? 'N/A',
1362                            'subscribed' => isset( $purchase['subscribed_date'] ) ? gmdate( 'Y-m-d', strtotime( $purchase['subscribed_date'] ) ) : 'N/A',
1363                            'expires'    => isset( $purchase['expiry_date'] ) ? gmdate( 'Y-m-d', strtotime( $purchase['expiry_date'] ) ) : 'N/A',
1364                            'auto_renew' => isset( $purchase['auto_renew'] ) ? ( $purchase['auto_renew'] ? 'Yes' : 'No' ) : 'N/A',
1365                        );
1366                    }
1367
1368                    $table_args = array( 'format' => 'table' );
1369                    $formatter  = new \WP_CLI\Formatter(
1370                        $table_args,
1371                        array( 'product', 'type', 'subscribed', 'expires', 'auto_renew' )
1372                    );
1373                    $formatter->display_items( $formatted_purchases );
1374                } else {
1375                    WP_CLI::log( 'No purchases found.' );
1376                }
1377            } else {
1378                WP_CLI::log( WP_CLI::colorize( '%RPurchases command failed:%n' ) );
1379                WP_CLI::log( $purchases_result->stderr );
1380            }
1381
1382            WP_CLI::log( '' );
1383
1384            // 6. PHP Errors (filtered to critical errors)
1385            WP_CLI::log( WP_CLI::colorize( '%Y--- Critical PHP Errors ---%n' ) );
1386            $error_log_file = '/tmp/php-errors';
1387
1388            if ( file_exists( $error_log_file ) ) {
1389                // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_shell_exec
1390                $output = shell_exec( "grep -E 'Fatal error|PHP Fatal error|Parse error|Uncaught Error|Uncaught Exception|TypeError|ArgumentCountError|Compile error' " . escapeshellarg( $error_log_file ) . ' | tail -n 100' );
1391
1392                if ( ! empty( trim( (string) $output ) ) ) {
1393                    WP_CLI::log( trim( (string) $output ) );
1394                } else {
1395                    WP_CLI::log( WP_CLI::colorize( '%GNo critical PHP errors found.%n' ) );
1396                }
1397            } else {
1398                WP_CLI::log( WP_CLI::colorize( '%RPHP errors file not found:%n /tmp/php-errors' ) );
1399            }
1400
1401            WP_CLI::log( '' );
1402            WP_CLI::log( WP_CLI::colorize( '%B=== DIAGNOSTICS COMPLETE ===%n' ) );
1403        }
1404    }
1405}
1406
1407if ( class_exists( 'Checksum_Plugin_Command' ) ) {
1408    /**
1409     * This works just like plugin verify-checksums except it filters language translation files.
1410     * Language files are not part of WordPress.org's checksums so they are listed as added and
1411     * they obfuscate the output. This makes it hard to spot actual checksum verification errors.
1412     */
1413    class Checksum_Plugin_Command_WPCOMSH extends Checksum_Plugin_Command { // phpcs:ignore Generic
1414        /**
1415         * Filters the passed file path.
1416         *
1417         * @param string $filepath File path.
1418         *
1419         * @return bool
1420         */
1421        protected function filter_file( $filepath ) {
1422            return ! preg_match( '#^(languages/)?[a-z0-9-]+-[a-z]{2}_[A-Z]{2}(_[a-z]+)?([.](mo|po)|-[a-f0-9]{32}[.]json)$#', $filepath );
1423        }
1424    }
1425}
1426
1427/**
1428 * Symlinks a managed plugin into the site's plugins directory.
1429 *
1430 * ## OPTIONS
1431 *
1432 * <plugin>
1433 * : The managed plugin to symlink.
1434 *
1435 * [--remove-unmanaged]
1436 * : Deprecated. If there is an unmanaged directory in the way, remove it without asking.
1437 *
1438 * [--remove-existing]
1439 * : If there is an existing directory or different symlink in the way, remove it without asking.
1440 *
1441 * [--activate]
1442 * : Indicates that the symlinked plugin should be activated
1443 *
1444 * @return never
1445 */
1446function wpcomsh_cli_plugin_symlink( $args, $assoc_args = array() ) {
1447    WP_CLI::warning( 'This command is deprecated. Please use the `wpcomsh plugin use-managed` command instead.' );
1448
1449    $plugin_to_symlink = $args[0];
1450
1451    if ( 'wpcomsh' === $plugin_to_symlink ) {
1452        // wpcomsh is in the managed plugins directory, but it should not be symlinked into the plugins directory.
1453        WP_CLI::error( 'Cannot symlink wpcomsh' );
1454    }
1455
1456    if ( ! chdir( WP_PLUGIN_DIR ) ) {
1457        WP_CLI::error( "Cannot switch to plugins directory '" . WP_PLUGIN_DIR . "'" );
1458    }
1459
1460    $managed_plugin_relative_path = "../../../../wordpress/plugins/$plugin_to_symlink/latest";
1461    if ( false === realpath( $managed_plugin_relative_path ) ) {
1462        WP_CLI::error( "'$plugin_to_symlink' is not a managed plugin" );
1463    }
1464
1465    $already_symlinked = false;
1466    if ( realpath( $plugin_to_symlink ) === realpath( $managed_plugin_relative_path ) ) {
1467        $already_symlinked = true;
1468    } elseif ( is_dir( $plugin_to_symlink ) ) {
1469        $permission_to_remove = false;
1470        if ( WP_CLI\Utils\get_flag_value( $assoc_args, 'remove-existing', false ) ) {
1471            $permission_to_remove = true;
1472        } elseif ( WP_CLI\Utils\get_flag_value( $assoc_args, 'remove-unmanaged', false ) ) {
1473            $permission_to_remove = true;
1474        } elseif ( wpcomsh_cli_confirm( "Plugin '$plugin_to_symlink' exists. Delete it and replace with symlink to managed plugin?" ) ) {
1475            $permission_to_remove = true;
1476        }
1477        if ( ! $permission_to_remove ) {
1478            exit( -1 );
1479        }
1480
1481        if ( is_link( $plugin_to_symlink ) ) {
1482            // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
1483            if ( ! unlink( $plugin_to_symlink ) ) {
1484                WP_CLI::error( "Failed to remove conflicting symlink '$plugin_to_symlink'" );
1485                exit( -1 );
1486            }
1487        } else {
1488            WP_CLI::runcommand(
1489                "--skip-plugins --skip-themes plugin delete '$plugin_to_symlink'",
1490                array(
1491                    'launch'     => false,
1492                    'exit_error' => true,
1493                )
1494            );
1495        }
1496    }
1497
1498    if ( $already_symlinked ) {
1499        WP_CLI::success( "Plugin '$plugin_to_symlink' is already symlinked" );
1500    } elseif ( symlink( $managed_plugin_relative_path, $plugin_to_symlink ) ) {
1501        WP_CLI::success( "Symlinked '$plugin_to_symlink' plugin" );
1502    } else {
1503        WP_CLI::error( "Failed to symlink '$plugin_to_symlink' plugin" );
1504        exit( -1 );
1505    }
1506
1507    $activate = WP_CLI\Utils\get_flag_value( $assoc_args, 'activate', false );
1508    if ( $activate ) {
1509
1510        // Invalidate cache so that the plugins can be read from the fs again.
1511        if ( ! $already_symlinked ) {
1512            wp_cache_delete( 'plugins', 'plugins' );
1513        }
1514
1515        WP_CLI::runcommand(
1516            "--skip-plugins --skip-themes plugin activate '$plugin_to_symlink'",
1517            array(
1518                'launch'     => false,
1519                'exit_error' => true,
1520            )
1521        );
1522    }
1523
1524    exit( 0 );
1525}
1526
1527/**
1528 * Symlinks a managed theme into the site's themes directory.
1529 *
1530 * ## OPTIONS
1531 *
1532 * <theme>
1533 * : The managed theme to symlink.
1534 *
1535 * [--remove-unmanaged]
1536 * : Deprecated. If there is an unmanaged directory in the way, remove it without asking.
1537 *
1538 * [--remove-existing]
1539 * : If there is an existing directory or different symlink in the way, remove it without asking.
1540 *
1541 * [--activate]
1542 * : Indicates that the symlinked theme should be activated
1543 *
1544 * @return never
1545 */
1546function wpcomsh_cli_theme_symlink( $args, $assoc_args = array() ) {
1547    WP_CLI::warning( 'This command is deprecated. Please use the `wpcomsh theme use-managed` command instead.' );
1548
1549    $theme_to_symlink = $args[0];
1550
1551    $themes_dir = get_theme_root();
1552    if ( ! chdir( $themes_dir ) ) {
1553        WP_CLI::error( "Cannot switch to themes directory '$themes_dir'" );
1554    }
1555
1556    $candidate_managed_theme_paths = array(
1557        // NOTE: pub and premium themes don't have nested `latest`and version directories.
1558        "../../../../wordpress/themes/pub/$theme_to_symlink",
1559        "../../../../wordpress/themes/premium/$theme_to_symlink",
1560        // Consider root themes dir last because we want to favor WPCOM-managed things on WPCOM
1561        // See p9o2xV-1LC-p2#comment-5417
1562        "../../../../wordpress/themes/$theme_to_symlink/latest",
1563    );
1564
1565    $managed_theme_path = false;
1566    foreach ( $candidate_managed_theme_paths as $candidate_path ) {
1567        if ( false !== realpath( $candidate_path ) ) {
1568            $managed_theme_path = $candidate_path;
1569            break;
1570        }
1571    }
1572
1573    if ( false === $managed_theme_path ) {
1574        WP_CLI::error( "'$theme_to_symlink' is not a managed theme" );
1575    }
1576
1577    $already_symlinked = false;
1578    if ( realpath( $theme_to_symlink ) === realpath( $managed_theme_path ) ) {
1579        $already_symlinked = true;
1580    } elseif ( is_dir( $theme_to_symlink ) ) {
1581        $permission_to_remove = false;
1582        if ( WP_CLI\Utils\get_flag_value( $assoc_args, 'remove-existing', false ) ) {
1583            $permission_to_remove = true;
1584        } elseif ( WP_CLI\Utils\get_flag_value( $assoc_args, 'remove-unmanaged', false ) ) {
1585            $permission_to_remove = true;
1586        } elseif ( wpcomsh_cli_confirm( "Theme '$theme_to_symlink' exists. Delete it and replace with symlink to managed theme?" ) ) {
1587            $permission_to_remove = true;
1588        }
1589        if ( ! $permission_to_remove ) {
1590            exit( -1 );
1591        }
1592
1593        if ( is_link( $theme_to_symlink ) ) {
1594            // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
1595            if ( ! unlink( $theme_to_symlink ) ) {
1596                WP_CLI::error( "Failed to remove conflicting symlink '$theme_to_symlink'" );
1597                exit( -1 );
1598            }
1599        } else {
1600            WP_CLI::runcommand(
1601                "--skip-plugins --skip-themes theme delete '$theme_to_symlink'",
1602                array(
1603                    'launch'     => false,
1604                    'exit_error' => true,
1605                )
1606            );
1607        }
1608    }
1609
1610    if ( $already_symlinked ) {
1611        WP_CLI::success( "Theme '$theme_to_symlink' is already symlinked" );
1612    } elseif ( symlink( $managed_theme_path, $theme_to_symlink ) ) {
1613        WP_CLI::success( "Symlinked '$theme_to_symlink' theme" );
1614    } else {
1615        WP_CLI::error( "Failed to symlink '$theme_to_symlink' theme" );
1616        exit( -1 );
1617    }
1618
1619    $activate = WP_CLI\Utils\get_flag_value( $assoc_args, 'activate', false );
1620    if ( $activate ) {
1621        WP_CLI::runcommand(
1622            "--skip-plugins --skip-themes theme activate '$theme_to_symlink'",
1623            array(
1624                'launch'     => false,
1625                'exit_error' => true,
1626            )
1627        );
1628    }
1629
1630    exit( 0 );
1631}
1632
1633/**
1634 * Makes the site live to the public.
1635 */
1636function wpcomsh_cli_launch_site() {
1637    WP_CLI::success( "If you're reading this, you should visit automattic.com/jobs and apply to join the fun, mention this command." );
1638}
1639
1640// Cleanup via WP-Cron event.
1641add_action( WPCOMSH_CLI_DEACTIVATED_PLUGIN_RECORD_CLEANUP_JOB, 'wpcomsh_cli_remove_expired_from_deactivation_record' );
1642
1643if ( ! defined( 'WP_CLI' ) || true !== WP_CLI ) {
1644    // We aren't running in a WP-CLI context, so there is nothing more to do.
1645    return;
1646}
1647
1648// Force WordPress to always output English at the command line.
1649WP_CLI::add_wp_hook(
1650    'pre_option_WPLANG',
1651    function () {
1652        return 'en_US';
1653    }
1654);
1655
1656// Maintain a record of deactivated plugins so that they can be reactivated by the reactivate-user-plugins command.
1657add_action( 'deactivated_plugin', 'wpcomsh_cli_remember_plugin_deactivation' );
1658add_action( 'activated_plugin', 'wpcomsh_cli_forget_plugin_deactivation' );
1659
1660WP_CLI::add_command( 'wpcomsh', 'WPCOMSH_CLI_Commands' );
1661WP_CLI::add_command( 'wpcomsh plugin verify-checksums', 'Checksum_Plugin_Command_WPCOMSH' );
1662WP_CLI::add_command( 'plugin symlink', 'wpcomsh_cli_plugin_symlink' );
1663WP_CLI::add_command( 'theme symlink', 'wpcomsh_cli_theme_symlink' );
1664WP_CLI::add_command( 'launch-site', 'wpcomsh_cli_launch_site' );
1665
1666add_action(
1667    'plugins_loaded',
1668    function () {
1669        if ( class_exists( 'Atomic_Platform_Managed_Software_Commands' ) ) {
1670            WP_CLI::add_command(
1671                'wpcomsh plugin use-managed',
1672                array( 'Atomic_Platform_Managed_Software_Commands', 'use_managed_plugin' )
1673            );
1674            WP_CLI::add_command(
1675                'wpcomsh plugin use-unmanaged',
1676                array( 'Atomic_Platform_Managed_Software_Commands', 'use_unmanaged_plugin' )
1677            );
1678            WP_CLI::add_command(
1679                'wpcomsh theme use-managed',
1680                array( 'Atomic_Platform_Managed_Software_Commands', 'use_managed_theme' )
1681            );
1682            WP_CLI::add_command(
1683                'wpcomsh theme use-unmanaged',
1684                array( 'Atomic_Platform_Managed_Software_Commands', 'use_unmanaged_theme' )
1685            );
1686        }
1687    }
1688);