Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 748
0.00% covered (danger)
0.00%
0 / 30
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 / 494
0.00% covered (danger)
0.00%
0 / 19
13572
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
 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() ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
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() ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
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() ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
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 ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
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         * Enable or disable fatal error emails.
926         *
927         * ## OPTIONS
928         *
929         * <command>
930         * : The subcommand
931         * ---
932         * options:
933         *  - get
934         *  - set
935         * ---
936         *
937         * [--value=<value>]
938         * : The value (when setting)
939         * ---
940         * default: 1
941         * options:
942         *  - 0
943         *  - 1
944         * ---
945         *
946         * @subcommand disable-fatal-error-emails
947         */
948        public function fatal_error_emails_disable( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
949            $command = $args[0];
950            $value   = (bool) $assoc_args['value'];
951
952            switch ( $command ) {
953                case 'get':
954                    $option = get_option( 'wpcomsh_disable_fatal_error_emails', false );
955                    WP_CLI::log( $option ? 'true' : 'false' );
956                    break;
957                case 'set':
958                    update_option( 'wpcomsh_disable_fatal_error_emails', $value );
959                    WP_CLI::success( 'Success' );
960                    break;
961                default:
962                    WP_CLI::error( 'Invalid command' );
963            }
964        }
965
966        /**
967         * Check if the site is healthy after activating a plugin.
968         * This is a helper function for the plugin-dance command.
969         *
970         * @return bool
971         */
972        private function do_plugin_dance_health_check() {
973            $result = WP_CLI::runcommand(
974                '--skip-themes= --skip-plugins= wpcomsh plugin-dance-health-check', // pass empty values to skip-themes and skip-plugins.
975                array(
976                    'return'     => true,
977                    'launch'     => true, // must run in a new process to avoid false positives.
978                    'exit_error' => false,
979                )
980            );
981
982            return (bool) strpos( $result, 'Healthy' );
983        }
984
985        /**
986         * Tries disabling all plugins & enabling them one by one to find the plugin causing the issue.
987         * Outputs a list of plugins that are disabled.
988         *
989         * ## OPTIONS
990         *
991         * [--strategy=<strategy>]
992         * : The strategy to use to find the breaking plugin. Defaults to 'one-by-one'.
993         * ---
994         * default: one-by-one
995         * options:
996         *  - one-by-one
997         *  - disable-all
998         *
999         * @subcommand plugin-dance
1000         */
1001        public function plugin_dance( $args, $assoc_args ) {
1002            $healthy = $this->do_plugin_dance_health_check();
1003            if ( $healthy ) {
1004                WP_CLI::success( '✔ Site health check passed before doing anything.' );
1005                return;
1006            }
1007
1008            $plugins = WP_CLI::runcommand(
1009                '--skip-plugins --skip-themes plugin list --status=active --format=json',
1010                array(
1011                    'launch' => false,
1012                    'return' => true,
1013                )
1014            );
1015
1016            $plugins = json_decode( $plugins, true );
1017
1018            // Filter out plugins we won't be touching. These won't be deactivated by deactivate-user-plugins.
1019            $plugins_to_reactivate = array_filter(
1020                $plugins,
1021                function ( $plugin ) {
1022                    $plugin_name = $plugin['name'];
1023                    if ( in_array( $plugin_name, WPCOMSH_CLI_DONT_DEACTIVATE_PLUGINS, true ) || in_array( $plugin_name, WPCOMSH_CLI_ECOMMERCE_PLAN_PLUGINS, true ) ) {
1024                        WP_CLI::log( sprintf( 'ℹ️ Skipping %s.', $plugin_name ) );
1025                        return false;
1026                    }
1027
1028                    return true;
1029                }
1030            );
1031
1032            $breaking_plugins = array();
1033
1034            if ( 'one-by-one' === $assoc_args['strategy'] ) {
1035                while ( ! $healthy ) {
1036                    $plugin_to_deactivate = array_pop( $plugins_to_reactivate );
1037                    if ( empty( $plugin_to_deactivate ) ) {
1038                        WP_CLI::error( '❌ Site health check failed after testing all plugins one by one.' );
1039                        return;
1040                    }
1041
1042                    WP_CLI::runcommand(
1043                        sprintf( '--skip-themes plugin deactivate %s', $plugin_to_deactivate['name'] ),
1044                        array(
1045                            'launch' => false,
1046                            'return' => true,
1047                        )
1048                    );
1049
1050                    $healthy = $this->do_plugin_dance_health_check();
1051
1052                    if ( ! $healthy ) {
1053                        WP_CLI::log( sprintf( 'ℹ️ Site health check still failed after deactivating: %s. Reactivating.', $plugin_to_deactivate['name'] ) );
1054                        $result = WP_CLI::runcommand(
1055                            sprintf( '--skip-themes plugin activate %s', $plugin_to_deactivate['name'] ),
1056                            array(
1057                                'launch'     => true,  // needed for exit_error => false.
1058                                'return'     => true,
1059                                'exit_error' => false,
1060                            )
1061                        );
1062
1063                        if ( empty( $result ) ) {
1064                            WP_CLI::log( sprintf( '❌ Plugin did not like being activated: %s (probably broken).', $plugin_to_deactivate['name'] ) );
1065                            $breaking_plugins[] = array(
1066                                'name'    => $plugin_to_deactivate['name'],
1067                                'version' => $plugin_to_deactivate['version'],
1068                            );
1069                        }
1070                    } else {
1071                        WP_CLI::log( sprintf( '✔ Site health check passed after deactivating: %s.', $plugin_to_deactivate['name'] ) );
1072                        $breaking_plugins[] = array(
1073                            'name'    => $plugin_to_deactivate['name'],
1074                            'version' => $plugin_to_deactivate['version'],
1075                        );
1076                    }
1077                }
1078            } elseif ( 'disable-all' === $assoc_args['strategy'] ) {
1079                WP_CLI::log( 'ℹ️ Deactivating all user plugins.' );
1080
1081                // deactivate all active plugins.
1082                WP_CLI::runcommand(
1083                    '--skip-plugins --skip-themes wpcomsh deactivate-user-plugins',
1084                    array(
1085                        'launch' => false,
1086                    )
1087                );
1088
1089                if ( ! $this->do_plugin_dance_health_check() ) {
1090                    WP_CLI::log( '❌ Site health check failed after deactivating all plugins. Something non-plugin related is causing the issue. Trying to reactivate all plugins.' );
1091
1092                    WP_CLI::runcommand(
1093                        '--skip-themes --skip-plugins wpcomsh reactivate-user-plugins',
1094                        array()
1095                    );
1096                    return;
1097                }
1098
1099                WP_CLI::log( sprintf( 'ℹ️ %d plugins will be reactivated one by one to find the breaking plugin.', count( $plugins_to_reactivate ) ) );
1100
1101                // loop through each active plugin and activate one by one.
1102                foreach ( $plugins_to_reactivate as $plugin ) {
1103                    $result = WP_CLI::runcommand(
1104                        sprintf( '--skip-themes plugin activate %s', $plugin['name'] ),
1105                        array(
1106                            'launch'     => true, // needed for exit_error => false.
1107                            'return'     => true,
1108                            'exit_error' => false,
1109                        )
1110                    );
1111                    if ( empty( $result ) ) {
1112                        WP_CLI::log( sprintf( '❌ Plugin did not like being activated: %s (probably broken).', $plugin['name'] ) );
1113                        $breaking_plugins[] = array(
1114                            'name'    => $plugin['name'],
1115                            'version' => $plugin['version'],
1116                        );
1117                        continue;
1118                    }
1119
1120                    if ( ! $this->do_plugin_dance_health_check() ) {
1121                        // deactivate the breaking plugin
1122                        WP_CLI::runcommand(
1123                            sprintf( '--skip-themes plugin deactivate %s', $plugin['name'] ),
1124                            array(
1125                                'launch' => false,
1126                                'return' => true,
1127                            )
1128                        );
1129                        WP_CLI::log( sprintf( '❌ Plugin activated, site health check failed and deactivated: %s.', $plugin['name'] ) );
1130
1131                        $breaking_plugins[] = array(
1132                            'name'    => $plugin['name'],
1133                            'version' => $plugin['version'],
1134                        );
1135                    } else {
1136                        WP_CLI::log( sprintf( '✔ Plugin activated and site health check passed: %s.', $plugin['name'] ) );
1137                    }
1138                }
1139
1140                if ( empty( $breaking_plugins ) ) {
1141                    WP_CLI::success( 'All plugins passed the site health check.' );
1142                }
1143            }
1144
1145            if ( ! empty( $breaking_plugins ) ) {
1146                $formatter = new \WP_CLI\Formatter(
1147                    $assoc_args,
1148                    array( 'name', 'version' )
1149                );
1150                $formatter->display_items( $breaking_plugins );
1151            }
1152        }
1153
1154        /**
1155         * This just outputs healthy. If there are errors this doesn't get outputted at all
1156         *
1157         * @subcommand plugin-dance-health-check
1158         */
1159        public function plugin_dance_health_check( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1160            WP_CLI::success( 'Healthy' );
1161        }
1162
1163        /**
1164         * Runs comprehensive site diagnostics including Jetpack status, admin users, plugins, purchases, and PHP errors
1165         *
1166         * @subcommand diag
1167         */
1168        public function diagnostic( $args, $assoc_args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1169            WP_CLI::log( WP_CLI::colorize( '%B=== SITE DIAGNOSTICS ===%n' ) );
1170            WP_CLI::log( '' );
1171
1172            // 1. Jetpack Status
1173            WP_CLI::log( WP_CLI::colorize( '%Y--- Jetpack Status ---%n' ) );
1174            $jetpack_result = WP_CLI::runcommand(
1175                'jetpack status full',
1176                array(
1177                    'launch'     => false,
1178                    'return'     => 'all',
1179                    'exit_error' => false,
1180                )
1181            );
1182
1183            if ( 0 === $jetpack_result->return_code ) {
1184                WP_CLI::log( $jetpack_result->stdout );
1185            } else {
1186                WP_CLI::log( WP_CLI::colorize( '%RJetpack status command failed:%n' ) );
1187                WP_CLI::log( $jetpack_result->stderr );
1188            }
1189
1190            WP_CLI::log( '' );
1191
1192            // 2. Admin Users
1193            WP_CLI::log( WP_CLI::colorize( '%Y--- Administrator Users ---%n' ) );
1194            $admin_users_result = WP_CLI::runcommand(
1195                'user list --role=administrator',
1196                array(
1197                    'launch'     => false,
1198                    'return'     => 'all',
1199                    'exit_error' => false,
1200                )
1201            );
1202
1203            if ( 0 === $admin_users_result->return_code ) {
1204                WP_CLI::log( $admin_users_result->stdout );
1205            } else {
1206                WP_CLI::log( WP_CLI::colorize( '%RAdmin users command failed:%n' ) );
1207                WP_CLI::log( $admin_users_result->stderr );
1208            }
1209
1210            WP_CLI::log( '' );
1211
1212            // 3. Plugin Status
1213            WP_CLI::log( WP_CLI::colorize( '%Y--- Plugin Status ---%n' ) );
1214            $plugin_status_result = WP_CLI::runcommand(
1215                'plugin status',
1216                array(
1217                    'launch'     => false,
1218                    'return'     => 'all',
1219                    'exit_error' => false,
1220                )
1221            );
1222
1223            if ( 0 === $plugin_status_result->return_code ) {
1224                WP_CLI::log( $plugin_status_result->stdout );
1225            } else {
1226                WP_CLI::log( WP_CLI::colorize( '%RPlugin status command failed:%n' ) );
1227                WP_CLI::log( $plugin_status_result->stderr );
1228            }
1229
1230            WP_CLI::log( '' );
1231
1232            // 4. Theme Status
1233            WP_CLI::log( WP_CLI::colorize( '%Y--- Theme Status ---%n' ) );
1234            $theme_status_result = WP_CLI::runcommand(
1235                'theme status',
1236                array(
1237                    'launch'     => false,
1238                    'return'     => 'all',
1239                    'exit_error' => false,
1240                )
1241            );
1242
1243            if ( 0 === $theme_status_result->return_code ) {
1244                WP_CLI::log( $theme_status_result->stdout );
1245            } else {
1246                WP_CLI::log( WP_CLI::colorize( '%RTheme status command failed:%n' ) );
1247                WP_CLI::log( $theme_status_result->stderr );
1248            }
1249
1250            WP_CLI::log( '' );
1251
1252            // 5. WPCOMSH Purchases (formatted as table)
1253            WP_CLI::log( WP_CLI::colorize( '%Y--- Site Purchases ---%n' ) );
1254            $purchases_result = WP_CLI::runcommand(
1255                'wpcomsh purchases --format=json',
1256                array(
1257                    'launch'     => false,
1258                    'return'     => 'all',
1259                    'exit_error' => false,
1260                )
1261            );
1262
1263            if ( 0 === $purchases_result->return_code ) {
1264                $purchases_data = json_decode( $purchases_result->stdout, true );
1265                if ( is_array( $purchases_data ) && ! empty( $purchases_data ) ) {
1266                    $formatted_purchases = array();
1267                    foreach ( $purchases_data as $purchase ) {
1268                        $formatted_purchases[] = array(
1269                            'product'    => $purchase['product_slug'] ?? 'N/A',
1270                            'type'       => $purchase['product_type'] ?? 'N/A',
1271                            'subscribed' => isset( $purchase['subscribed_date'] ) ? gmdate( 'Y-m-d', strtotime( $purchase['subscribed_date'] ) ) : 'N/A',
1272                            'expires'    => isset( $purchase['expiry_date'] ) ? gmdate( 'Y-m-d', strtotime( $purchase['expiry_date'] ) ) : 'N/A',
1273                            'auto_renew' => isset( $purchase['auto_renew'] ) ? ( $purchase['auto_renew'] ? 'Yes' : 'No' ) : 'N/A',
1274                        );
1275                    }
1276
1277                    $table_args = array( 'format' => 'table' );
1278                    $formatter  = new \WP_CLI\Formatter(
1279                        $table_args,
1280                        array( 'product', 'type', 'subscribed', 'expires', 'auto_renew' )
1281                    );
1282                    $formatter->display_items( $formatted_purchases );
1283                } else {
1284                    WP_CLI::log( 'No purchases found.' );
1285                }
1286            } else {
1287                WP_CLI::log( WP_CLI::colorize( '%RPurchases command failed:%n' ) );
1288                WP_CLI::log( $purchases_result->stderr );
1289            }
1290
1291            WP_CLI::log( '' );
1292
1293            // 6. PHP Errors (filtered to critical errors)
1294            WP_CLI::log( WP_CLI::colorize( '%Y--- Critical PHP Errors ---%n' ) );
1295            $error_log_file = '/tmp/php-errors';
1296
1297            if ( file_exists( $error_log_file ) ) {
1298                // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_shell_exec
1299                $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' );
1300
1301                if ( ! empty( trim( (string) $output ) ) ) {
1302                    WP_CLI::log( trim( (string) $output ) );
1303                } else {
1304                    WP_CLI::log( WP_CLI::colorize( '%GNo critical PHP errors found.%n' ) );
1305                }
1306            } else {
1307                WP_CLI::log( WP_CLI::colorize( '%RPHP errors file not found:%n /tmp/php-errors' ) );
1308            }
1309
1310            WP_CLI::log( '' );
1311            WP_CLI::log( WP_CLI::colorize( '%B=== DIAGNOSTICS COMPLETE ===%n' ) );
1312        }
1313    }
1314}
1315
1316if ( class_exists( 'Checksum_Plugin_Command' ) ) {
1317    /**
1318     * This works just like plugin verify-checksums except it filters language translation files.
1319     * Language files are not part of WordPress.org's checksums so they are listed as added and
1320     * they obfuscate the output. This makes it hard to spot actual checksum verification errors.
1321     */
1322    class Checksum_Plugin_Command_WPCOMSH extends Checksum_Plugin_Command { // phpcs:ignore Generic
1323        /**
1324         * Filters the passed file path.
1325         *
1326         * @param string $filepath File path.
1327         *
1328         * @return bool
1329         */
1330        protected function filter_file( $filepath ) {
1331            return ! preg_match( '#^(languages/)?[a-z0-9-]+-[a-z]{2}_[A-Z]{2}(_[a-z]+)?([.](mo|po)|-[a-f0-9]{32}[.]json)$#', $filepath );
1332        }
1333    }
1334}
1335
1336/**
1337 * Symlinks a managed plugin into the site's plugins directory.
1338 *
1339 * ## OPTIONS
1340 *
1341 * <plugin>
1342 * : The managed plugin to symlink.
1343 *
1344 * [--remove-unmanaged]
1345 * : Deprecated. If there is an unmanaged directory in the way, remove it without asking.
1346 *
1347 * [--remove-existing]
1348 * : If there is an existing directory or different symlink in the way, remove it without asking.
1349 *
1350 * [--activate]
1351 * : Indicates that the symlinked plugin should be activated
1352 *
1353 * @return never
1354 */
1355function wpcomsh_cli_plugin_symlink( $args, $assoc_args = array() ) {
1356    WP_CLI::warning( 'This command is deprecated. Please use the `wpcomsh plugin use-managed` command instead.' );
1357
1358    $plugin_to_symlink = $args[0];
1359
1360    if ( 'wpcomsh' === $plugin_to_symlink ) {
1361        // wpcomsh is in the managed plugins directory, but it should not be symlinked into the plugins directory.
1362        WP_CLI::error( 'Cannot symlink wpcomsh' );
1363    }
1364
1365    if ( ! chdir( WP_PLUGIN_DIR ) ) {
1366        WP_CLI::error( "Cannot switch to plugins directory '" . WP_PLUGIN_DIR . "'" );
1367    }
1368
1369    $managed_plugin_relative_path = "../../../../wordpress/plugins/$plugin_to_symlink/latest";
1370    if ( false === realpath( $managed_plugin_relative_path ) ) {
1371        WP_CLI::error( "'$plugin_to_symlink' is not a managed plugin" );
1372    }
1373
1374    $already_symlinked = false;
1375    if ( realpath( $plugin_to_symlink ) === realpath( $managed_plugin_relative_path ) ) {
1376        $already_symlinked = true;
1377    } elseif ( is_dir( $plugin_to_symlink ) ) {
1378        $permission_to_remove = false;
1379        if ( WP_CLI\Utils\get_flag_value( $assoc_args, 'remove-existing', false ) ) {
1380            $permission_to_remove = true;
1381        } elseif ( WP_CLI\Utils\get_flag_value( $assoc_args, 'remove-unmanaged', false ) ) {
1382            $permission_to_remove = true;
1383        } elseif ( wpcomsh_cli_confirm( "Plugin '$plugin_to_symlink' exists. Delete it and replace with symlink to managed plugin?" ) ) {
1384            $permission_to_remove = true;
1385        }
1386        if ( ! $permission_to_remove ) {
1387            exit( -1 );
1388        }
1389
1390        if ( is_link( $plugin_to_symlink ) ) {
1391            // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
1392            if ( ! unlink( $plugin_to_symlink ) ) {
1393                WP_CLI::error( "Failed to remove conflicting symlink '$plugin_to_symlink'" );
1394                exit( -1 );
1395            }
1396        } else {
1397            WP_CLI::runcommand(
1398                "--skip-plugins --skip-themes plugin delete '$plugin_to_symlink'",
1399                array(
1400                    'launch'     => false,
1401                    'exit_error' => true,
1402                )
1403            );
1404        }
1405    }
1406
1407    if ( $already_symlinked ) {
1408        WP_CLI::success( "Plugin '$plugin_to_symlink' is already symlinked" );
1409    } elseif ( symlink( $managed_plugin_relative_path, $plugin_to_symlink ) ) {
1410        WP_CLI::success( "Symlinked '$plugin_to_symlink' plugin" );
1411    } else {
1412        WP_CLI::error( "Failed to symlink '$plugin_to_symlink' plugin" );
1413        exit( -1 );
1414    }
1415
1416    $activate = WP_CLI\Utils\get_flag_value( $assoc_args, 'activate', false );
1417    if ( $activate ) {
1418
1419        // Invalidate cache so that the plugins can be read from the fs again.
1420        if ( ! $already_symlinked ) {
1421            wp_cache_delete( 'plugins', 'plugins' );
1422        }
1423
1424        WP_CLI::runcommand(
1425            "--skip-plugins --skip-themes plugin activate '$plugin_to_symlink'",
1426            array(
1427                'launch'     => false,
1428                'exit_error' => true,
1429            )
1430        );
1431    }
1432
1433    exit( 0 );
1434}
1435
1436/**
1437 * Symlinks a managed theme into the site's themes directory.
1438 *
1439 * ## OPTIONS
1440 *
1441 * <theme>
1442 * : The managed theme to symlink.
1443 *
1444 * [--remove-unmanaged]
1445 * : Deprecated. If there is an unmanaged directory in the way, remove it without asking.
1446 *
1447 * [--remove-existing]
1448 * : If there is an existing directory or different symlink in the way, remove it without asking.
1449 *
1450 * [--activate]
1451 * : Indicates that the symlinked theme should be activated
1452 *
1453 * @return never
1454 */
1455function wpcomsh_cli_theme_symlink( $args, $assoc_args = array() ) {
1456    WP_CLI::warning( 'This command is deprecated. Please use the `wpcomsh theme use-managed` command instead.' );
1457
1458    $theme_to_symlink = $args[0];
1459
1460    $themes_dir = get_theme_root();
1461    if ( ! chdir( $themes_dir ) ) {
1462        WP_CLI::error( "Cannot switch to themes directory '$themes_dir'" );
1463    }
1464
1465    $candidate_managed_theme_paths = array(
1466        // NOTE: pub and premium themes don't have nested `latest`and version directories.
1467        "../../../../wordpress/themes/pub/$theme_to_symlink",
1468        "../../../../wordpress/themes/premium/$theme_to_symlink",
1469        // Consider root themes dir last because we want to favor WPCOM-managed things on WPCOM
1470        // See p9o2xV-1LC-p2#comment-5417
1471        "../../../../wordpress/themes/$theme_to_symlink/latest",
1472    );
1473
1474    $managed_theme_path = false;
1475    foreach ( $candidate_managed_theme_paths as $candidate_path ) {
1476        if ( false !== realpath( $candidate_path ) ) {
1477            $managed_theme_path = $candidate_path;
1478            break;
1479        }
1480    }
1481
1482    if ( false === $managed_theme_path ) {
1483        WP_CLI::error( "'$theme_to_symlink' is not a managed theme" );
1484    }
1485
1486    $already_symlinked = false;
1487    if ( realpath( $theme_to_symlink ) === realpath( $managed_theme_path ) ) {
1488        $already_symlinked = true;
1489    } elseif ( is_dir( $theme_to_symlink ) ) {
1490        $permission_to_remove = false;
1491        if ( WP_CLI\Utils\get_flag_value( $assoc_args, 'remove-existing', false ) ) {
1492            $permission_to_remove = true;
1493        } elseif ( WP_CLI\Utils\get_flag_value( $assoc_args, 'remove-unmanaged', false ) ) {
1494            $permission_to_remove = true;
1495        } elseif ( wpcomsh_cli_confirm( "Theme '$theme_to_symlink' exists. Delete it and replace with symlink to managed theme?" ) ) {
1496            $permission_to_remove = true;
1497        }
1498        if ( ! $permission_to_remove ) {
1499            exit( -1 );
1500        }
1501
1502        if ( is_link( $theme_to_symlink ) ) {
1503            // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
1504            if ( ! unlink( $theme_to_symlink ) ) {
1505                WP_CLI::error( "Failed to remove conflicting symlink '$theme_to_symlink'" );
1506                exit( -1 );
1507            }
1508        } else {
1509            WP_CLI::runcommand(
1510                "--skip-plugins --skip-themes theme delete '$theme_to_symlink'",
1511                array(
1512                    'launch'     => false,
1513                    'exit_error' => true,
1514                )
1515            );
1516        }
1517    }
1518
1519    if ( $already_symlinked ) {
1520        WP_CLI::success( "Theme '$theme_to_symlink' is already symlinked" );
1521    } elseif ( symlink( $managed_theme_path, $theme_to_symlink ) ) {
1522        WP_CLI::success( "Symlinked '$theme_to_symlink' theme" );
1523    } else {
1524        WP_CLI::error( "Failed to symlink '$theme_to_symlink' theme" );
1525        exit( -1 );
1526    }
1527
1528    $activate = WP_CLI\Utils\get_flag_value( $assoc_args, 'activate', false );
1529    if ( $activate ) {
1530        WP_CLI::runcommand(
1531            "--skip-plugins --skip-themes theme activate '$theme_to_symlink'",
1532            array(
1533                'launch'     => false,
1534                'exit_error' => true,
1535            )
1536        );
1537    }
1538
1539    exit( 0 );
1540}
1541
1542/**
1543 * Makes the site live to the public.
1544 */
1545function wpcomsh_cli_launch_site() {
1546    WP_CLI::success( "If you're reading this, you should visit automattic.com/jobs and apply to join the fun, mention this command." );
1547}
1548
1549// Cleanup via WP-Cron event.
1550add_action( WPCOMSH_CLI_DEACTIVATED_PLUGIN_RECORD_CLEANUP_JOB, 'wpcomsh_cli_remove_expired_from_deactivation_record' );
1551
1552if ( ! defined( 'WP_CLI' ) || true !== WP_CLI ) {
1553    // We aren't running in a WP-CLI context, so there is nothing more to do.
1554    return;
1555}
1556
1557// Force WordPress to always output English at the command line.
1558WP_CLI::add_wp_hook(
1559    'pre_option_WPLANG',
1560    function () {
1561        return 'en_US';
1562    }
1563);
1564
1565// Maintain a record of deactivated plugins so that they can be reactivated by the reactivate-user-plugins command.
1566add_action( 'deactivated_plugin', 'wpcomsh_cli_remember_plugin_deactivation' );
1567add_action( 'activated_plugin', 'wpcomsh_cli_forget_plugin_deactivation' );
1568
1569WP_CLI::add_command( 'wpcomsh', 'WPCOMSH_CLI_Commands' );
1570WP_CLI::add_command( 'wpcomsh plugin verify-checksums', 'Checksum_Plugin_Command_WPCOMSH' );
1571WP_CLI::add_command( 'plugin symlink', 'wpcomsh_cli_plugin_symlink' );
1572WP_CLI::add_command( 'theme symlink', 'wpcomsh_cli_theme_symlink' );
1573WP_CLI::add_command( 'launch-site', 'wpcomsh_cli_launch_site' );
1574
1575add_action(
1576    'plugins_loaded',
1577    function () {
1578        if ( class_exists( 'Atomic_Platform_Managed_Software_Commands' ) ) {
1579            WP_CLI::add_command(
1580                'wpcomsh plugin use-managed',
1581                array( 'Atomic_Platform_Managed_Software_Commands', 'use_managed_plugin' )
1582            );
1583            WP_CLI::add_command(
1584                'wpcomsh plugin use-unmanaged',
1585                array( 'Atomic_Platform_Managed_Software_Commands', 'use_unmanaged_plugin' )
1586            );
1587            WP_CLI::add_command(
1588                'wpcomsh theme use-managed',
1589                array( 'Atomic_Platform_Managed_Software_Commands', 'use_managed_theme' )
1590            );
1591            WP_CLI::add_command(
1592                'wpcomsh theme use-unmanaged',
1593                array( 'Atomic_Platform_Managed_Software_Commands', 'use_unmanaged_theme' )
1594            );
1595        }
1596    }
1597);