Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 1147
0.00% covered (danger)
0.00%
0 / 35
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 / 893
0.00% covered (danger)
0.00%
0 / 24
22052
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
 php83_plugin_patches
0.00% covered (danger)
0.00%
0 / 269
0.00% covered (danger)
0.00%
0 / 1
2
 php_83_plugin_patch
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
110
 php83_theme_patches
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
12
 php_83_theme_patch
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 apply_php83_patch
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
182
 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 definitions for the php83-plugin-patch subcommand.
926         *
927         * PHP 8.3 turned a repeated `static` declaration of the same variable in one
928         * function scope into a "Duplicate declaration of static variable" fatal
929         * error, which old plugin versions trip over. Each entry describes a minimal,
930         * behavior-preserving rename of the duplicate declaration (DOTCOM-17392).
931         *
932         * Shared keys:
933         * - folders:        plugin directory names to look for (vendors ship the same
934         *                   product under different folder slugs).
935         * - file:           the file to patch, relative to the plugin directory.
936         * - patched_marker: string that only exists after patching; makes re-runs a
937         *                   safe no-op.
938         * - strategy:       'split_rename' renames every occurrence of `search` after
939         *                   `split_marker`; 'replace_once' applies exact search/replace
940         *                   pairs that must each match exactly once.
941         *
942         * @return array<string,array> Patch definitions keyed by plugin slug.
943         */
944        private static function php83_plugin_patches() {
945            return array(
946
947                /*
948                 * The `mm_options_generator()` function declares `static $theme_option_file`
949                 * in both the `file` and `background_image` cases of the same switch. This
950                 * renames the copy in the `background_image` case so only a single
951                 * declaration of `$theme_option_file` remains. Both cases merely enqueue
952                 * the media uploader scripts, and `wp_enqueue_*` is idempotent, so behavior
953                 * is unchanged. The same product ships as both `mega_main_menu` and
954                 * `mega-main-menu`.
955                 */
956                'mega_main_menu'          => array(
957                    'folders'        => array( 'mega_main_menu', 'mega-main-menu' ),
958                    'file'           => 'framework/options_generator.php',
959                    'patched_marker' => '$theme_option_file_background',
960                    'strategy'       => 'split_rename',
961                    'split_marker'   => "case 'background_image':",
962                    'search'         => '$theme_option_file',
963                    'replace'        => '$theme_option_file_background',
964                ),
965
966                /*
967                 * `custom_columns()` in includes/Admin/CPT/Submission.php declares
968                 * `static $fields` in both the fieldset-repeater branch and the
969                 * numeric-column branch of the same function (unchanged 3.6.14–3.6.28).
970                 * Both are per-column memo caches keyed by `$column`; renaming the
971                 * numeric-branch copy just splits the cache in two, so behavior is
972                 * unchanged.
973                 */
974                'ninja-forms'             => array(
975                    'folders'        => array( 'ninja-forms' ),
976                    'file'           => 'includes/Admin/CPT/Submission.php',
977                    'patched_marker' => '$fields_by_column',
978                    'strategy'       => 'replace_once',
979                    'replacements'   => array(
980                        array(
981                            "            static \$fields;\n"
982                            . "            if( ! isset( \$fields[ \$column ] ) ) {\n"
983                            . "                \$fields[\$column] = Ninja_Forms()->form( \$form_id )->get_field( \$column );\n"
984                            . "            }\n"
985                            . '            $field = $fields[$column];',
986                            "            // Renamed by wpcomsh to avoid a PHP 8.3+ \"Duplicate declaration of\n"
987                            . "            // static variable\" fatal: the repeater branch above also declares\n"
988                            . "            // `static \$fields`.\n"
989                            . "            static \$fields_by_column;\n"
990                            . "            if( ! isset( \$fields_by_column[ \$column ] ) ) {\n"
991                            . "                \$fields_by_column[\$column] = Ninja_Forms()->form( \$form_id )->get_field( \$column );\n"
992                            . "            }\n"
993                            . '            $field = $fields_by_column[$column];',
994                        ),
995                    ),
996                ),
997
998                /*
999                 * `theme_list()` in modules/theme_purchase.php declares
1000                 * `static $header_tags` twice — two identical, constant `wp_kses()`
1001                 * allowlists in different loops of the same function (unchanged
1002                 * 4.2.7–4.3.5). The second declaration is nested one level deeper, so
1003                 * its 5-tab indentation uniquely identifies it. Renaming a constant
1004                 * allowlist is behavior-neutral.
1005                 */
1006                'wiziapp-create-your-own-native-iphone-app' => array(
1007                    'folders'        => array( 'wiziapp-create-your-own-native-iphone-app' ),
1008                    'file'           => 'modules/theme_purchase.php',
1009                    'patched_marker' => '$header_tags_title',
1010                    'strategy'       => 'replace_once',
1011                    'replacements'   => array(
1012                        array(
1013                            "\t\t\t\t\tstatic \$header_tags = array(",
1014                            "\t\t\t\t\t// Renamed by wpcomsh to avoid a PHP 8.3+ \"Duplicate declaration\n"
1015                            . "\t\t\t\t\t// of static variable\" fatal: theme_list() declares this allowlist twice.\n"
1016                            . "\t\t\t\t\tstatic \$header_tags_title = array(",
1017                        ),
1018                        array(
1019                            "\t\t\t\t\t\$theme['title'] = wp_kses(\$theme['title'], \$header_tags);",
1020                            "\t\t\t\t\t\$theme['title'] = wp_kses(\$theme['title'], \$header_tags_title);",
1021                        ),
1022                    ),
1023                ),
1024
1025                /*
1026                 * `initData()` in inc/classes/thegem-blocks.php declares
1027                 * `static $globalColors;` twice in the same declaration list, two lines
1028                 * apart — a plain copy/paste bug (verified on 1.0.2 and 1.1.x installs).
1029                 * Deleting the second declaration is behavior-neutral. The plugin ships
1030                 * as both `thegem-blocks-elementor` and `-thegem-blocks-elementor`.
1031                 */
1032                'thegem-blocks-elementor' => array(
1033                    'folders'        => array( 'thegem-blocks-elementor', '-thegem-blocks-elementor' ),
1034                    'file'           => 'inc/classes/thegem-blocks.php',
1035                    'patched_marker' => 'removed a duplicate `static $globalColors;`',
1036                    'strategy'       => 'replace_once',
1037                    'replacements'   => array(
1038                        array(
1039                            "        static \$dummyListByUrl;\n        static \$globalColors;",
1040                            '        static $dummyListByUrl; // wpcomsh: removed a duplicate `static $globalColors;` here (PHP 8.3+ fatal); it is declared two lines above.',
1041                        ),
1042                    ),
1043                ),
1044
1045                /*
1046                 * `deep_magazine_ele_ajax()` declares three static variables — the
1047                 * shared counters `$magazin_uniqid`, `$title_uniqid`, and `$uniqid` —
1048                 * once per magazine-type branch (three branches each). PHP stops
1049                 * compiling at the first duplicate, so the fatals surfaced one
1050                 * variable at a time; a full audit of the file shows all of them.
1051                 *
1052                 * `$magazin_uniqid` and `$title_uniqid` are hoisted to the top of the
1053                 * function (one shared slot, initial 0 — identical to the pre-8.3
1054                 * compile-time semantics) and the branch declarations become plain
1055                 * increments. `$uniqid` cannot be hoisted under its own name: the
1056                 * function assigns `$uniqid` from request input as a plain local
1057                 * before any branch declaration binds the static slot, so a hoisted
1058                 * binding would let that assignment clobber the counter. Instead a
1059                 * `$deep_loop_uniqid` slot is hoisted and each branch declaration
1060                 * becomes a reference binding (`$uniqid = &$deep_loop_uniqid;`),
1061                 * which reproduces the original bind-at-this-point behavior exactly.
1062                 *
1063                 * The vendor shipped the same 2.1.2 code with two line endings, so
1064                 * the patch carries a CRLF set (also handling files already carrying
1065                 * the first-round magazin-only hoist) and an LF set for pristine
1066                 * files.
1067                 */
1068                'deepcore'                => array(
1069                    'folders'          => array( 'deepcore' ),
1070                    'file'             => 'src/components/functions/functions-general.php',
1071                    'patched_marker'   => '$deep_loop_uniqid',
1072                    'strategy'         => 'replace_once',
1073                    'replacement_sets' => array(
1074                        // CRLF file that already has the first-round magazin hoist.
1075                        array(
1076                            array(
1077                                "\t// wpcomsh: hoisted out of the type branches below (PHP 8.3+ duplicate-static fatal).\r\n\tstatic \$magazin_uniqid = 0;\r\n",
1078                                "\t// wpcomsh: hoisted out of the type branches below (PHP 8.3+ duplicate-static fatal).\r\n\tstatic \$magazin_uniqid = 0;\r\n\tstatic \$title_uniqid = 0;\r\n\tstatic \$deep_loop_uniqid = 0;\r\n",
1079                            ),
1080                            array(
1081                                "\t\t<?php static \$title_uniqid = 0; \$title_uniqid++; ?>\r\n",
1082                                "\t\t<?php \$title_uniqid++; ?>\r\n",
1083                            ),
1084                            array(
1085                                "\t\tstatic \$title_uniqid = 0;\r\n\t\t\$title_uniqid++;",
1086                                "\t\t\$title_uniqid++;",
1087                                2,
1088                            ),
1089                            array(
1090                                "\n\t\t\t\tstatic \$uniqid = 0;\r",
1091                                "\n\t\t\t\t\$uniqid = &\$deep_loop_uniqid;\r",
1092                            ),
1093                            array(
1094                                "\n\t\t\t\t\tstatic \$uniqid = 0;\r",
1095                                "\n\t\t\t\t\t\$uniqid = &\$deep_loop_uniqid;\r",
1096                            ),
1097                            array(
1098                                "\n\t\t\t\t\t\tstatic \$uniqid = 0;\r",
1099                                "\n\t\t\t\t\t\t\$uniqid = &\$deep_loop_uniqid;\r",
1100                            ),
1101                        ),
1102                        // Pristine LF file (same 2.1.2 code, normalized line endings).
1103                        array(
1104                            array(
1105                                "function deep_magazine_ele_ajax() {\n",
1106                                "function deep_magazine_ele_ajax() {\n\t// wpcomsh: hoisted out of the type branches below (PHP 8.3+ duplicate-static fatal).\n\tstatic \$magazin_uniqid = 0;\n\tstatic \$title_uniqid = 0;\n\tstatic \$deep_loop_uniqid = 0;\n",
1107                            ),
1108                            array(
1109                                "\t\tstatic \$magazin_uniqid = 0;\n\t\t\$magazin_uniqid++;",
1110                                "\t\t\$magazin_uniqid++;",
1111                                3,
1112                            ),
1113                            array(
1114                                "\t\t<?php static \$title_uniqid = 0; \$title_uniqid++; ?>\n",
1115                                "\t\t<?php \$title_uniqid++; ?>\n",
1116                            ),
1117                            array(
1118                                "\t\tstatic \$title_uniqid = 0;\n\t\t\$title_uniqid++;",
1119                                "\t\t\$title_uniqid++;",
1120                                2,
1121                            ),
1122                            array(
1123                                "\n\t\t\t\tstatic \$uniqid = 0;\n",
1124                                "\n\t\t\t\t\$uniqid = &\$deep_loop_uniqid;\n",
1125                            ),
1126                            array(
1127                                "\n\t\t\t\t\tstatic \$uniqid = 0;\n",
1128                                "\n\t\t\t\t\t\$uniqid = &\$deep_loop_uniqid;\n",
1129                            ),
1130                            array(
1131                                "\n\t\t\t\t\t\tstatic \$uniqid = 0;\n",
1132                                "\n\t\t\t\t\t\t\$uniqid = &\$deep_loop_uniqid;\n",
1133                            ),
1134                        ),
1135                    ),
1136                ),
1137
1138                /*
1139                 * deepcore duplicates the same magazine markup — with the same three
1140                 * per-branch static counters — a second time in the Elementor widget's
1141                 * render() (src/components/elementor/widgets/magazine.php). It only
1142                 * compiles on pages that use the magazine widget, so it surfaces
1143                 * separately from the functions-general.php copy. Same treatment:
1144                 * hoist `$magazin_uniqid`/`$title_uniqid`, reference-bind `$uniqid`
1145                 * to a hoisted `$deep_widget_uniqid` slot. Registered under its own
1146                 * key (same `deepcore` folder) so targeted runs can patch this file
1147                 * explicitly: `wp wpcomsh php83-plugin-patch deepcore-widget`.
1148                 */
1149                'deepcore-widget'         => array(
1150                    'folders'          => array( 'deepcore' ),
1151                    'file'             => 'src/components/elementor/widgets/magazine.php',
1152                    'patched_marker'   => '$deep_widget_uniqid',
1153                    'strategy'         => 'replace_once',
1154                    'replacement_sets' => array(
1155                        // CRLF variant.
1156                        array(
1157                            array(
1158                                "\tprotected function render() {\r\n",
1159                                "\tprotected function render() {\r\n\t\t// wpcomsh: hoisted out of the type branches below (PHP 8.3+ duplicate-static fatal).\r\n\t\tstatic \$magazin_uniqid = 0;\r\n\t\tstatic \$title_uniqid = 0;\r\n\t\tstatic \$deep_widget_uniqid = 0;\r\n",
1160                            ),
1161                            array(
1162                                "\t\t\tstatic \$magazin_uniqid = 0;\r\n\t\t\t\$magazin_uniqid++;",
1163                                "\t\t\t\$magazin_uniqid++;",
1164                                3,
1165                            ),
1166                            array(
1167                                "\t\t\t<?php static \$title_uniqid = 0; \$title_uniqid++; ?>\r\n",
1168                                "\t\t\t<?php \$title_uniqid++; ?>\r\n",
1169                            ),
1170                            array(
1171                                "\t\t\tstatic \$title_uniqid = 0;\r\n\t\t\t\$title_uniqid++;",
1172                                "\t\t\t\$title_uniqid++;",
1173                                2,
1174                            ),
1175                            array(
1176                                "\n\t\t\t\t\tstatic \$uniqid = 0;\r",
1177                                "\n\t\t\t\t\t\$uniqid = &\$deep_widget_uniqid;\r",
1178                            ),
1179                            array(
1180                                "\n\t\t\t\t\t\tstatic \$uniqid = 0;\r",
1181                                "\n\t\t\t\t\t\t\$uniqid = &\$deep_widget_uniqid;\r",
1182                            ),
1183                            array(
1184                                "\n\t\t\t\t\t\t\tstatic \$uniqid = 0;\r",
1185                                "\n\t\t\t\t\t\t\t\$uniqid = &\$deep_widget_uniqid;\r",
1186                            ),
1187                        ),
1188                        // LF variant (same code, normalized line endings).
1189                        array(
1190                            array(
1191                                "\tprotected function render() {\n",
1192                                "\tprotected function render() {\n\t\t// wpcomsh: hoisted out of the type branches below (PHP 8.3+ duplicate-static fatal).\n\t\tstatic \$magazin_uniqid = 0;\n\t\tstatic \$title_uniqid = 0;\n\t\tstatic \$deep_widget_uniqid = 0;\n",
1193                            ),
1194                            array(
1195                                "\t\t\tstatic \$magazin_uniqid = 0;\n\t\t\t\$magazin_uniqid++;",
1196                                "\t\t\t\$magazin_uniqid++;",
1197                                3,
1198                            ),
1199                            array(
1200                                "\t\t\t<?php static \$title_uniqid = 0; \$title_uniqid++; ?>\n",
1201                                "\t\t\t<?php \$title_uniqid++; ?>\n",
1202                            ),
1203                            array(
1204                                "\t\t\tstatic \$title_uniqid = 0;\n\t\t\t\$title_uniqid++;",
1205                                "\t\t\t\$title_uniqid++;",
1206                                2,
1207                            ),
1208                            array(
1209                                "\n\t\t\t\t\tstatic \$uniqid = 0;\n",
1210                                "\n\t\t\t\t\t\$uniqid = &\$deep_widget_uniqid;\n",
1211                            ),
1212                            array(
1213                                "\n\t\t\t\t\t\tstatic \$uniqid = 0;\n",
1214                                "\n\t\t\t\t\t\t\$uniqid = &\$deep_widget_uniqid;\n",
1215                            ),
1216                            array(
1217                                "\n\t\t\t\t\t\t\tstatic \$uniqid = 0;\n",
1218                                "\n\t\t\t\t\t\t\t\$uniqid = &\$deep_widget_uniqid;\n",
1219                            ),
1220                        ),
1221                    ),
1222                ),
1223
1224                /*
1225                 * `MalinaGridPosts()` in inc/shortcodes.php declares `static $i = 0;` in
1226                 * both the style_3 and style_7 branches (shared layout counter). Hoisted
1227                 * to the top of the function, branch declarations deleted. The file uses
1228                 * CRLF line endings; the searches are newline-anchored so the 5-tab
1229                 * declaration cannot match inside the 6-tab one.
1230                 */
1231                'malina-elements'         => array(
1232                    'folders'        => array( 'malina-elements' ),
1233                    'file'           => 'inc/shortcodes.php',
1234                    'patched_marker' => 'wpcomsh: hoisted',
1235                    'strategy'       => 'replace_once',
1236                    'replacements'   => array(
1237                        array(
1238                            "\tfunction MalinaGridPosts(\$atts, \$content = null){\r\n",
1239                            "\tfunction MalinaGridPosts(\$atts, \$content = null){\r\n\t\t// wpcomsh: hoisted out of the style branches below (PHP 8.3+ duplicate-static fatal).\r\n\t\tstatic \$i = 0;\r\n",
1240                        ),
1241                        array(
1242                            "\n\t\t\t\t\t\tstatic \$i = 0;\r",
1243                            '',
1244                        ),
1245                        array(
1246                            "\n\t\t\t\t\tstatic \$i = 0;\r",
1247                            '',
1248                        ),
1249                    ),
1250                ),
1251
1252                /*
1253                 * `admin_notice()` in includes/ld-autoupdate.php (LearnDash 3.2.x) was
1254                 * hand-edited on the site: a `static $notice_shown = true; # code by Tj`
1255                 * line sits directly below the stock `static $notice_shown = false;`
1256                 * declaration to suppress the license notice. Pre-8.3 the last
1257                 * initializer won (true); keeping the site's customized line and
1258                 * removing the stock one preserves that behavior exactly.
1259                 */
1260                'sfwd-lms'                => array(
1261                    'folders'        => array( 'sfwd-lms' ),
1262                    'file'           => 'includes/ld-autoupdate.php',
1263                    'patched_marker' => 'wpcomsh: removed the duplicate',
1264                    'strategy'       => 'replace_once',
1265                    'replacements'   => array(
1266                        array(
1267                            "\t\t\tstatic \$notice_shown = false;\n\t\t\tstatic \$notice_shown = true; # code by Tj",
1268                            "\t\t\t// wpcomsh: removed the duplicate stock declaration (PHP 8.3+ fatal); the site-customized line below is the effective initializer.\n\t\t\tstatic \$notice_shown = true; # code by Tj",
1269                        ),
1270                    ),
1271                ),
1272
1273                /*
1274                 * `ecoist_gallery_layouts_shortcode()` declares
1275                 * `static $image_counter_classic = 1;` in two gallery-layout branches
1276                 * (shared element counter). Hoisted to the top of the function, branch
1277                 * declarations deleted.
1278                 */
1279                'cp-addons-for-vc'        => array(
1280                    'folders'        => array( 'cp-addons-for-vc' ),
1281                    'file'           => 'modules/ecoist_gallery_layouts.php',
1282                    'patched_marker' => 'wpcomsh: hoisted',
1283                    'strategy'       => 'replace_once',
1284                    'replacements'   => array(
1285                        array(
1286                            "\t\tfunction ecoist_gallery_layouts_shortcode( \$atts, \$content = null ) {",
1287                            "\t\tfunction ecoist_gallery_layouts_shortcode( \$atts, \$content = null ) {\n\t\t\t// wpcomsh: hoisted out of the layout branches below (PHP 8.3+ duplicate-static fatal).\n\t\t\tstatic \$image_counter_classic = 1;",
1288                        ),
1289                        array(
1290                            "\n\t\t\t\t\t\t\t\t\t\t\t\t\tstatic \$image_counter_classic = 1;",
1291                            '',
1292                            2,
1293                        ),
1294                    ),
1295                ),
1296
1297                /*
1298                 * The Elementor widget's `render()` in
1299                 * elementor-widgets/widgets_classes/gallery_layouts.php declares
1300                 * `static $counter = 1;` in three gallery-layout branches (shared
1301                 * element counter; same vendored code family as cp-addons-for-vc).
1302                 * Hoisted to the top of render(), branch declarations deleted.
1303                 */
1304                'ingeniofy'               => array(
1305                    'folders'        => array( 'ingeniofy' ),
1306                    'file'           => 'elementor-widgets/widgets_classes/gallery_layouts.php',
1307                    'patched_marker' => 'wpcomsh: hoisted',
1308                    'strategy'       => 'replace_once',
1309                    'replacements'   => array(
1310                        array(
1311                            '  protected function render(){',
1312                            "  protected function render(){\n    // wpcomsh: hoisted out of the layout branches below (PHP 8.3+ duplicate-static fatal).\n    static \$counter = 1;",
1313                        ),
1314                        array(
1315                            "\n\t\t\t\t\t\t\t\tstatic \$counter = 1;",
1316                            '',
1317                            3,
1318                        ),
1319                    ),
1320                ),
1321            );
1322        }
1323
1324        /**
1325         * Patch a plugin to work with PHP 8.3+.
1326         *
1327         * Applies a minimal, behavior-preserving rename of a duplicate `static`
1328         * variable declaration that fatals on PHP 8.3+ ("Duplicate declaration of
1329         * static variable"). See php83_plugin_patches() for the per-plugin rationale.
1330         *
1331         * ## OPTIONS
1332         *
1333         * <plugin>
1334         * : The plugin to patch. Any of the plugin's known folder slugs is accepted.
1335         *
1336         * [--force]
1337         * : Patch even when an update for the plugin is pending. Only for cases
1338         *   where the update exists but cannot be installed (dead license,
1339         *   broken vendor download).
1340         *
1341         * @subcommand php83-plugin-patch
1342         */
1343        public function php_83_plugin_patch( $args, $assoc_args ) {
1344            $patches = self::php83_plugin_patches();
1345            $slug    = $args[0];
1346
1347            // Accept any of a patch's folder slugs as the argument: the error logs
1348            // that drive the patch jobs report whichever folder the vendor shipped.
1349            if ( ! isset( $patches[ $slug ] ) ) {
1350                foreach ( $patches as $key => $candidate ) {
1351                    if ( in_array( $slug, $candidate['folders'], true ) ) {
1352                        $slug = $key;
1353                        break;
1354                    }
1355                }
1356            }
1357
1358            if ( ! isset( $patches[ $slug ] ) ) {
1359                WP_CLI::error( sprintf( 'No PHP 8.3 patch for %s. Available: %s.', $slug, implode( ', ', array_keys( $patches ) ) ) );
1360            }
1361
1362            $patch = $patches[ $slug ];
1363
1364            // Resolve which of the plugin's known folder slugs is installed.
1365            $plugin_file = null;
1366            foreach ( array_keys( get_plugins() ) as $file ) {
1367                $file = (string) $file;
1368                if ( in_array( dirname( $file ), $patch['folders'], true ) ) {
1369                    $plugin_file = $file;
1370                    break;
1371                }
1372            }
1373
1374            if ( null === $plugin_file ) {
1375                WP_CLI::error( "$slug plugin is not installed." );
1376            }
1377
1378            // Don't patch if an update is pending: the new version may already fix this,
1379            // and an update would overwrite the patched file anyway. Refresh the
1380            // transient first so the decision is based on current data.
1381            if ( ! \WP_CLI\Utils\get_flag_value( $assoc_args, 'force', false ) ) {
1382                wp_update_plugins();
1383                $update_plugins = get_site_transient( 'update_plugins' );
1384
1385                if ( isset( $update_plugins->response[ $plugin_file ] ) ) {
1386                    $new_version = $update_plugins->response[ $plugin_file ]->new_version ?? 'unknown';
1387                    WP_CLI::error( "An update to $slug $new_version is available; update the plugin instead of patching, or re-run with --force if the update cannot be installed." );
1388                }
1389            }
1390
1391            $file = WP_PLUGIN_DIR . '/' . dirname( (string) $plugin_file ) . '/' . $patch['file'];
1392
1393            $this->apply_php83_patch( $file, $patch );
1394        }
1395
1396        /**
1397         * Patch definitions for the php83-theme-patch subcommand.
1398         *
1399         * Same duplicate-static-variable fatal as php83_plugin_patches(), for themes.
1400         * Two vendor families cover every affected theme:
1401         *
1402         * - SecondLine podcast themes share inc/audio-functions.php, where
1403         *   `secondline_powerpress_options()` declares `static $secondline_player_called;`
1404         *   at the top and then `static $secondline_player_called = true;` inside a
1405         *   branch. Pre-8.3, the branch initializer set the slot's compile-time initial
1406         *   value (true); dropping the duplicate `static` keyword and keeping a plain
1407         *   `= true` assignment in that branch reproduces the same values on every path.
1408         *   These theme files use CRLF line endings.
1409         *
1410         * - Select-Themes themes share lib/functions/meta.php, where the theme-prefixed
1411         *   `*_print_meta_box()` declares `static $muindex = 1;` in both the
1412         *   `mediaupload` and `mediaupload_standard` switch cases. The counter feeds
1413         *   unique uploader DOM ids shared across both cases, so it is hoisted to the
1414         *   top of the function (one shared slot, initial 1 — identical to the pre-8.3
1415         *   compile-time semantics) and the case declarations are deleted. `hazel1` is
1416         *   a site-local copy of hazel and shares its function prefix.
1417         *
1418         * @return array<string,array> Patch definitions keyed by theme slug.
1419         */
1420        private static function php83_theme_patches() {
1421            $patches = array();
1422
1423            // Older SecondLine builds ship CRLF line endings, newer (licensed)
1424            // builds ship LF; the target line is byte-identical otherwise.
1425            $secondline_replacement_sets = array(
1426                array(
1427                    array(
1428                        "\t\t\tstatic \$secondline_player_called = true;\r\n",
1429                        "\t\t\t\$secondline_player_called = true; // wpcomsh: dropped a duplicate `static` keyword (PHP 8.3+ fatal); the variable is declared static at the top of this function.\r\n",
1430                    ),
1431                ),
1432                array(
1433                    array(
1434                        "\t\t\tstatic \$secondline_player_called = true;\n",
1435                        "\t\t\t\$secondline_player_called = true; // wpcomsh: dropped a duplicate `static` keyword (PHP 8.3+ fatal); the variable is declared static at the top of this function.\n",
1436                    ),
1437                ),
1438            );
1439
1440            foreach ( array( 'tusant-secondline', 'gumbo-secondline', 'dixie-secondline', 'satchmo-secondline', 'bolden-secondline' ) as $slug ) {
1441                $patches[ $slug ] = array(
1442                    'file'             => 'inc/audio-functions.php',
1443                    'patched_marker'   => 'wpcomsh: dropped a duplicate',
1444                    'strategy'         => 'replace_once',
1445                    'replacement_sets' => $secondline_replacement_sets,
1446                );
1447            }
1448
1449            $select_themes = array(
1450                'hazel'    => 'hazel',
1451                'hazel1'   => 'hazel',
1452                'alma'     => 'alma',
1453                'maple'    => 'maple',
1454                'rhythm'   => 'rhythm',
1455                'architek' => 'architek',
1456                'skudo'    => 'skudo',
1457                'larch-1'  => 'larch',
1458            );
1459
1460            foreach ( $select_themes as $slug => $function_prefix ) {
1461                $patches[ $slug ] = array(
1462                    'file'           => 'lib/functions/meta.php',
1463                    'patched_marker' => 'wpcomsh: hoisted',
1464                    'strategy'       => 'replace_once',
1465                    'replacements'   => array(
1466                        array(
1467                            "function {$function_prefix}_print_meta_box(\$meta_box, \$post){",
1468                            "function {$function_prefix}_print_meta_box(\$meta_box, \$post){\n\t// wpcomsh: hoisted out of the mediaupload cases below (PHP 8.3+ duplicate-static fatal).\n\tstatic \$muindex = 1;",
1469                        ),
1470                        array(
1471                            "\n\t\t\tstatic \$muindex = 1;",
1472                            '',
1473                            2,
1474                        ),
1475                    ),
1476                );
1477            }
1478
1479            return $patches;
1480        }
1481
1482        /**
1483         * Patch a theme to work with PHP 8.3+.
1484         *
1485         * Applies a minimal, behavior-preserving fix for a duplicate `static`
1486         * variable declaration that fatals on PHP 8.3+ ("Duplicate declaration of
1487         * static variable"). See php83_theme_patches() for the per-theme rationale.
1488         *
1489         * ## OPTIONS
1490         *
1491         * <theme>
1492         * : The theme to patch.
1493         *
1494         * [--force]
1495         * : Patch even when an update for the theme is pending. Only for cases
1496         *   where the update exists but cannot be installed (dead license,
1497         *   broken vendor download).
1498         *
1499         * @subcommand php83-theme-patch
1500         */
1501        public function php_83_theme_patch( $args, $assoc_args ) {
1502            $patches = self::php83_theme_patches();
1503            $slug    = $args[0];
1504
1505            if ( ! isset( $patches[ $slug ] ) ) {
1506                WP_CLI::error( sprintf( 'No PHP 8.3 patch for %s. Available: %s.', $slug, implode( ', ', array_keys( $patches ) ) ) );
1507            }
1508
1509            $patch = $patches[ $slug ];
1510            $theme = wp_get_theme( $slug );
1511
1512            if ( ! $theme->exists() ) {
1513                WP_CLI::error( "$slug theme is not installed." );
1514            }
1515
1516            // Don't patch if an update is pending: the new version may already fix this,
1517            // and an update would overwrite the patched file anyway. Refresh the
1518            // transient first so the decision is based on current data.
1519            if ( ! \WP_CLI\Utils\get_flag_value( $assoc_args, 'force', false ) ) {
1520                wp_update_themes();
1521                $update_themes = get_site_transient( 'update_themes' );
1522
1523                if ( isset( $update_themes->response[ $slug ] ) ) {
1524                    $new_version = $update_themes->response[ $slug ]['new_version'] ?? 'unknown';
1525                    WP_CLI::error( "An update to $slug $new_version is available; update the theme instead of patching, or re-run with --force if the update cannot be installed." );
1526                }
1527            }
1528
1529            $file = $theme->get_stylesheet_directory() . '/' . $patch['file'];
1530
1531            $this->apply_php83_patch( $file, $patch );
1532        }
1533
1534        /**
1535         * Apply one php83 patch definition to a file on disk.
1536         *
1537         * Exits via WP_CLI::error() when the file or any patch target does not look
1538         * exactly as expected — an unknown vendor version is a reason to stop, not
1539         * to guess.
1540         *
1541         * @param string $file  Absolute path of the file to patch.
1542         * @param array  $patch Patch definition (see php83_plugin_patches()).
1543         */
1544        private function apply_php83_patch( $file, array $patch ) {
1545            if ( ! file_exists( $file ) ) {
1546                WP_CLI::error( 'File not found: ' . $file );
1547            }
1548
1549            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
1550            $file_content = file_get_contents( $file );
1551
1552            if ( false === $file_content ) {
1553                WP_CLI::error( 'File not readable: ' . $file );
1554            }
1555
1556            // Already patched: re-running is a safe no-op.
1557            if ( false !== strpos( $file_content, $patch['patched_marker'] ) ) {
1558                WP_CLI::success( 'Already patched.' );
1559                return;
1560            }
1561
1562            if ( 'split_rename' === $patch['strategy'] ) {
1563                // Rename every occurrence of `search` after `split_marker`, leaving the
1564                // original declaration before the marker untouched.
1565                $marker_pos = strpos( $file_content, $patch['split_marker'] );
1566
1567                if ( false === $marker_pos ) {
1568                    WP_CLI::error( 'Patch target not found in ' . $file );
1569                }
1570
1571                $before = substr( $file_content, 0, $marker_pos );
1572                $after  = substr( $file_content, $marker_pos );
1573
1574                $count = 0;
1575                $after = str_replace( $patch['search'], $patch['replace'], $after, $count );
1576
1577                if ( ! $count ) {
1578                    WP_CLI::error( 'String not found on ' . $file );
1579                }
1580
1581                $file_content = $before . $after;
1582            } else {
1583                // replace_once: every pair must match exactly as many times as expected
1584                // (default 1), or the installed version differs from the one the patch
1585                // was written against. A patch may carry several 'replacement_sets'
1586                // (e.g. CRLF and LF variants of the same vendor file); the first set
1587                // whose first pair matches is applied in full.
1588                $sets = $patch['replacement_sets'] ?? array( $patch['replacements'] );
1589                $set  = null;
1590
1591                foreach ( $sets as $candidate ) {
1592                    if ( substr_count( $file_content, $candidate[0][0] ) === ( $candidate[0][2] ?? 1 ) ) {
1593                        $set = $candidate;
1594                        break;
1595                    }
1596                }
1597
1598                if ( null === $set ) {
1599                    WP_CLI::error( sprintf( 'No replacement set matches %s — unknown vendor version?', $file ) );
1600                }
1601
1602                foreach ( $set as $replacement ) {
1603                    $search      = $replacement[0];
1604                    $replace     = $replacement[1];
1605                    $expected    = $replacement[2] ?? 1;
1606                    $occurrences = substr_count( $file_content, $search );
1607
1608                    if ( $expected !== $occurrences ) {
1609                        WP_CLI::error( sprintf( 'Expected exactly %d match(es) in %s, found %d — unknown vendor version?', $expected, $file, $occurrences ) );
1610                    }
1611
1612                    $file_content = str_replace( $search, $replace, $file_content );
1613                }
1614            }
1615
1616            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
1617            if ( ! file_put_contents( $file, $file_content ) ) {
1618                WP_CLI::error( 'Failed to write to ' . $file );
1619            }
1620
1621            WP_CLI::success( 'Success' );
1622        }
1623
1624        /**
1625         * Enable or disable fatal error emails.
1626         *
1627         * ## OPTIONS
1628         *
1629         * <command>
1630         * : The subcommand
1631         * ---
1632         * options:
1633         *  - get
1634         *  - set
1635         * ---
1636         *
1637         * [--value=<value>]
1638         * : The value (when setting)
1639         * ---
1640         * default: 1
1641         * options:
1642         *  - 0
1643         *  - 1
1644         * ---
1645         *
1646         * @subcommand disable-fatal-error-emails
1647         */
1648        public function fatal_error_emails_disable( $args, $assoc_args ) {
1649            $command = $args[0];
1650            $value   = (bool) $assoc_args['value'];
1651
1652            switch ( $command ) {
1653                case 'get':
1654                    $option = get_option( 'wpcomsh_disable_fatal_error_emails', false );
1655                    WP_CLI::log( $option ? 'true' : 'false' );
1656                    break;
1657                case 'set':
1658                    update_option( 'wpcomsh_disable_fatal_error_emails', $value );
1659                    WP_CLI::success( 'Success' );
1660                    break;
1661                default:
1662                    WP_CLI::error( 'Invalid command' );
1663            }
1664        }
1665
1666        /**
1667         * Check if the site is healthy after activating a plugin.
1668         * This is a helper function for the plugin-dance command.
1669         *
1670         * @return bool
1671         */
1672        private function do_plugin_dance_health_check() {
1673            $result = WP_CLI::runcommand(
1674                '--skip-themes= --skip-plugins= wpcomsh plugin-dance-health-check', // pass empty values to skip-themes and skip-plugins.
1675                array(
1676                    'return'     => true,
1677                    'launch'     => true, // must run in a new process to avoid false positives.
1678                    'exit_error' => false,
1679                )
1680            );
1681
1682            return (bool) strpos( $result, 'Healthy' );
1683        }
1684
1685        /**
1686         * Tries disabling all plugins & enabling them one by one to find the plugin causing the issue.
1687         * Outputs a list of plugins that are disabled.
1688         *
1689         * ## OPTIONS
1690         *
1691         * [--strategy=<strategy>]
1692         * : The strategy to use to find the breaking plugin. Defaults to 'one-by-one'.
1693         * ---
1694         * default: one-by-one
1695         * options:
1696         *  - one-by-one
1697         *  - disable-all
1698         *
1699         * @subcommand plugin-dance
1700         */
1701        public function plugin_dance( $args, $assoc_args ) {
1702            $healthy = $this->do_plugin_dance_health_check();
1703            if ( $healthy ) {
1704                WP_CLI::success( '✔ Site health check passed before doing anything.' );
1705                return;
1706            }
1707
1708            $plugins = WP_CLI::runcommand(
1709                '--skip-plugins --skip-themes plugin list --status=active --format=json',
1710                array(
1711                    'launch' => false,
1712                    'return' => true,
1713                )
1714            );
1715
1716            $plugins = json_decode( $plugins, true );
1717
1718            // Filter out plugins we won't be touching. These won't be deactivated by deactivate-user-plugins.
1719            $plugins_to_reactivate = array_filter(
1720                $plugins,
1721                function ( $plugin ) {
1722                    $plugin_name = $plugin['name'];
1723                    if ( in_array( $plugin_name, WPCOMSH_CLI_DONT_DEACTIVATE_PLUGINS, true ) || in_array( $plugin_name, WPCOMSH_CLI_ECOMMERCE_PLAN_PLUGINS, true ) ) {
1724                        WP_CLI::log( sprintf( 'ℹ️ Skipping %s.', $plugin_name ) );
1725                        return false;
1726                    }
1727
1728                    return true;
1729                }
1730            );
1731
1732            $breaking_plugins = array();
1733
1734            if ( 'one-by-one' === $assoc_args['strategy'] ) {
1735                while ( ! $healthy ) {
1736                    $plugin_to_deactivate = array_pop( $plugins_to_reactivate );
1737                    if ( empty( $plugin_to_deactivate ) ) {
1738                        WP_CLI::error( '❌ Site health check failed after testing all plugins one by one.' );
1739                        return;
1740                    }
1741
1742                    WP_CLI::runcommand(
1743                        sprintf( '--skip-themes plugin deactivate %s', $plugin_to_deactivate['name'] ),
1744                        array(
1745                            'launch' => false,
1746                            'return' => true,
1747                        )
1748                    );
1749
1750                    $healthy = $this->do_plugin_dance_health_check();
1751
1752                    if ( ! $healthy ) {
1753                        WP_CLI::log( sprintf( 'ℹ️ Site health check still failed after deactivating: %s. Reactivating.', $plugin_to_deactivate['name'] ) );
1754                        $result = WP_CLI::runcommand(
1755                            sprintf( '--skip-themes plugin activate %s', $plugin_to_deactivate['name'] ),
1756                            array(
1757                                'launch'     => true,  // needed for exit_error => false.
1758                                'return'     => true,
1759                                'exit_error' => false,
1760                            )
1761                        );
1762
1763                        if ( empty( $result ) ) {
1764                            WP_CLI::log( sprintf( '❌ Plugin did not like being activated: %s (probably broken).', $plugin_to_deactivate['name'] ) );
1765                            $breaking_plugins[] = array(
1766                                'name'    => $plugin_to_deactivate['name'],
1767                                'version' => $plugin_to_deactivate['version'],
1768                            );
1769                        }
1770                    } else {
1771                        WP_CLI::log( sprintf( '✔ Site health check passed after deactivating: %s.', $plugin_to_deactivate['name'] ) );
1772                        $breaking_plugins[] = array(
1773                            'name'    => $plugin_to_deactivate['name'],
1774                            'version' => $plugin_to_deactivate['version'],
1775                        );
1776                    }
1777                }
1778            } elseif ( 'disable-all' === $assoc_args['strategy'] ) {
1779                WP_CLI::log( 'ℹ️ Deactivating all user plugins.' );
1780
1781                // deactivate all active plugins.
1782                WP_CLI::runcommand(
1783                    '--skip-plugins --skip-themes wpcomsh deactivate-user-plugins',
1784                    array(
1785                        'launch' => false,
1786                    )
1787                );
1788
1789                if ( ! $this->do_plugin_dance_health_check() ) {
1790                    WP_CLI::log( '❌ Site health check failed after deactivating all plugins. Something non-plugin related is causing the issue. Trying to reactivate all plugins.' );
1791
1792                    WP_CLI::runcommand(
1793                        '--skip-themes --skip-plugins wpcomsh reactivate-user-plugins',
1794                        array()
1795                    );
1796                    return;
1797                }
1798
1799                WP_CLI::log( sprintf( 'ℹ️ %d plugins will be reactivated one by one to find the breaking plugin.', count( $plugins_to_reactivate ) ) );
1800
1801                // loop through each active plugin and activate one by one.
1802                foreach ( $plugins_to_reactivate as $plugin ) {
1803                    $result = WP_CLI::runcommand(
1804                        sprintf( '--skip-themes plugin activate %s', $plugin['name'] ),
1805                        array(
1806                            'launch'     => true, // needed for exit_error => false.
1807                            'return'     => true,
1808                            'exit_error' => false,
1809                        )
1810                    );
1811                    if ( empty( $result ) ) {
1812                        WP_CLI::log( sprintf( '❌ Plugin did not like being activated: %s (probably broken).', $plugin['name'] ) );
1813                        $breaking_plugins[] = array(
1814                            'name'    => $plugin['name'],
1815                            'version' => $plugin['version'],
1816                        );
1817                        continue;
1818                    }
1819
1820                    if ( ! $this->do_plugin_dance_health_check() ) {
1821                        // deactivate the breaking plugin
1822                        WP_CLI::runcommand(
1823                            sprintf( '--skip-themes plugin deactivate %s', $plugin['name'] ),
1824                            array(
1825                                'launch' => false,
1826                                'return' => true,
1827                            )
1828                        );
1829                        WP_CLI::log( sprintf( '❌ Plugin activated, site health check failed and deactivated: %s.', $plugin['name'] ) );
1830
1831                        $breaking_plugins[] = array(
1832                            'name'    => $plugin['name'],
1833                            'version' => $plugin['version'],
1834                        );
1835                    } else {
1836                        WP_CLI::log( sprintf( '✔ Plugin activated and site health check passed: %s.', $plugin['name'] ) );
1837                    }
1838                }
1839
1840                if ( empty( $breaking_plugins ) ) {
1841                    WP_CLI::success( 'All plugins passed the site health check.' );
1842                }
1843            }
1844
1845            if ( ! empty( $breaking_plugins ) ) {
1846                $formatter = new \WP_CLI\Formatter(
1847                    $assoc_args,
1848                    array( 'name', 'version' )
1849                );
1850                $formatter->display_items( $breaking_plugins );
1851            }
1852        }
1853
1854        /**
1855         * This just outputs healthy. If there are errors this doesn't get outputted at all
1856         *
1857         * @subcommand plugin-dance-health-check
1858         */
1859        public function plugin_dance_health_check( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1860            WP_CLI::success( 'Healthy' );
1861        }
1862
1863        /**
1864         * Runs comprehensive site diagnostics including Jetpack status, admin users, plugins, purchases, and PHP errors
1865         *
1866         * @subcommand diag
1867         */
1868        public function diagnostic( $args, $assoc_args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1869            WP_CLI::log( WP_CLI::colorize( '%B=== SITE DIAGNOSTICS ===%n' ) );
1870            WP_CLI::log( '' );
1871
1872            // 1. Jetpack Status
1873            WP_CLI::log( WP_CLI::colorize( '%Y--- Jetpack Status ---%n' ) );
1874            $jetpack_result = WP_CLI::runcommand(
1875                'jetpack status full',
1876                array(
1877                    'launch'     => false,
1878                    'return'     => 'all',
1879                    'exit_error' => false,
1880                )
1881            );
1882
1883            if ( 0 === $jetpack_result->return_code ) {
1884                WP_CLI::log( $jetpack_result->stdout );
1885            } else {
1886                WP_CLI::log( WP_CLI::colorize( '%RJetpack status command failed:%n' ) );
1887                WP_CLI::log( $jetpack_result->stderr );
1888            }
1889
1890            WP_CLI::log( '' );
1891
1892            // 2. Admin Users
1893            WP_CLI::log( WP_CLI::colorize( '%Y--- Administrator Users ---%n' ) );
1894            $admin_users_result = WP_CLI::runcommand(
1895                'user list --role=administrator',
1896                array(
1897                    'launch'     => false,
1898                    'return'     => 'all',
1899                    'exit_error' => false,
1900                )
1901            );
1902
1903            if ( 0 === $admin_users_result->return_code ) {
1904                WP_CLI::log( $admin_users_result->stdout );
1905            } else {
1906                WP_CLI::log( WP_CLI::colorize( '%RAdmin users command failed:%n' ) );
1907                WP_CLI::log( $admin_users_result->stderr );
1908            }
1909
1910            WP_CLI::log( '' );
1911
1912            // 3. Plugin Status
1913            WP_CLI::log( WP_CLI::colorize( '%Y--- Plugin Status ---%n' ) );
1914            $plugin_status_result = WP_CLI::runcommand(
1915                'plugin status',
1916                array(
1917                    'launch'     => false,
1918                    'return'     => 'all',
1919                    'exit_error' => false,
1920                )
1921            );
1922
1923            if ( 0 === $plugin_status_result->return_code ) {
1924                WP_CLI::log( $plugin_status_result->stdout );
1925            } else {
1926                WP_CLI::log( WP_CLI::colorize( '%RPlugin status command failed:%n' ) );
1927                WP_CLI::log( $plugin_status_result->stderr );
1928            }
1929
1930            WP_CLI::log( '' );
1931
1932            // 4. Theme Status
1933            WP_CLI::log( WP_CLI::colorize( '%Y--- Theme Status ---%n' ) );
1934            $theme_status_result = WP_CLI::runcommand(
1935                'theme status',
1936                array(
1937                    'launch'     => false,
1938                    'return'     => 'all',
1939                    'exit_error' => false,
1940                )
1941            );
1942
1943            if ( 0 === $theme_status_result->return_code ) {
1944                WP_CLI::log( $theme_status_result->stdout );
1945            } else {
1946                WP_CLI::log( WP_CLI::colorize( '%RTheme status command failed:%n' ) );
1947                WP_CLI::log( $theme_status_result->stderr );
1948            }
1949
1950            WP_CLI::log( '' );
1951
1952            // 5. WPCOMSH Purchases (formatted as table)
1953            WP_CLI::log( WP_CLI::colorize( '%Y--- Site Purchases ---%n' ) );
1954            $purchases_result = WP_CLI::runcommand(
1955                'wpcomsh purchases --format=json',
1956                array(
1957                    'launch'     => false,
1958                    'return'     => 'all',
1959                    'exit_error' => false,
1960                )
1961            );
1962
1963            if ( 0 === $purchases_result->return_code ) {
1964                $purchases_data = json_decode( $purchases_result->stdout, true );
1965                if ( is_array( $purchases_data ) && ! empty( $purchases_data ) ) {
1966                    $formatted_purchases = array();
1967                    foreach ( $purchases_data as $purchase ) {
1968                        $formatted_purchases[] = array(
1969                            'product'    => $purchase['product_slug'] ?? 'N/A',
1970                            'type'       => $purchase['product_type'] ?? 'N/A',
1971                            'subscribed' => isset( $purchase['subscribed_date'] ) ? gmdate( 'Y-m-d', strtotime( $purchase['subscribed_date'] ) ) : 'N/A',
1972                            'expires'    => isset( $purchase['expiry_date'] ) ? gmdate( 'Y-m-d', strtotime( $purchase['expiry_date'] ) ) : 'N/A',
1973                            'auto_renew' => isset( $purchase['auto_renew'] ) ? ( $purchase['auto_renew'] ? 'Yes' : 'No' ) : 'N/A',
1974                        );
1975                    }
1976
1977                    $table_args = array( 'format' => 'table' );
1978                    $formatter  = new \WP_CLI\Formatter(
1979                        $table_args,
1980                        array( 'product', 'type', 'subscribed', 'expires', 'auto_renew' )
1981                    );
1982                    $formatter->display_items( $formatted_purchases );
1983                } else {
1984                    WP_CLI::log( 'No purchases found.' );
1985                }
1986            } else {
1987                WP_CLI::log( WP_CLI::colorize( '%RPurchases command failed:%n' ) );
1988                WP_CLI::log( $purchases_result->stderr );
1989            }
1990
1991            WP_CLI::log( '' );
1992
1993            // 6. PHP Errors (filtered to critical errors)
1994            WP_CLI::log( WP_CLI::colorize( '%Y--- Critical PHP Errors ---%n' ) );
1995            $error_log_file = '/tmp/php-errors';
1996
1997            if ( file_exists( $error_log_file ) ) {
1998                // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_shell_exec
1999                $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' );
2000
2001                if ( ! empty( trim( (string) $output ) ) ) {
2002                    WP_CLI::log( trim( (string) $output ) );
2003                } else {
2004                    WP_CLI::log( WP_CLI::colorize( '%GNo critical PHP errors found.%n' ) );
2005                }
2006            } else {
2007                WP_CLI::log( WP_CLI::colorize( '%RPHP errors file not found:%n /tmp/php-errors' ) );
2008            }
2009
2010            WP_CLI::log( '' );
2011            WP_CLI::log( WP_CLI::colorize( '%B=== DIAGNOSTICS COMPLETE ===%n' ) );
2012        }
2013    }
2014}
2015
2016if ( class_exists( 'Checksum_Plugin_Command' ) ) {
2017    /**
2018     * This works just like plugin verify-checksums except it filters language translation files.
2019     * Language files are not part of WordPress.org's checksums so they are listed as added and
2020     * they obfuscate the output. This makes it hard to spot actual checksum verification errors.
2021     */
2022    class Checksum_Plugin_Command_WPCOMSH extends Checksum_Plugin_Command { // phpcs:ignore Generic
2023        /**
2024         * Filters the passed file path.
2025         *
2026         * @param string $filepath File path.
2027         *
2028         * @return bool
2029         */
2030        protected function filter_file( $filepath ) {
2031            return ! preg_match( '#^(languages/)?[a-z0-9-]+-[a-z]{2}_[A-Z]{2}(_[a-z]+)?([.](mo|po)|-[a-f0-9]{32}[.]json)$#', $filepath );
2032        }
2033    }
2034}
2035
2036/**
2037 * Symlinks a managed plugin into the site's plugins directory.
2038 *
2039 * ## OPTIONS
2040 *
2041 * <plugin>
2042 * : The managed plugin to symlink.
2043 *
2044 * [--remove-unmanaged]
2045 * : Deprecated. If there is an unmanaged directory in the way, remove it without asking.
2046 *
2047 * [--remove-existing]
2048 * : If there is an existing directory or different symlink in the way, remove it without asking.
2049 *
2050 * [--activate]
2051 * : Indicates that the symlinked plugin should be activated
2052 *
2053 * @return never
2054 */
2055function wpcomsh_cli_plugin_symlink( $args, $assoc_args = array() ) {
2056    WP_CLI::warning( 'This command is deprecated. Please use the `wpcomsh plugin use-managed` command instead.' );
2057
2058    $plugin_to_symlink = $args[0];
2059
2060    if ( 'wpcomsh' === $plugin_to_symlink ) {
2061        // wpcomsh is in the managed plugins directory, but it should not be symlinked into the plugins directory.
2062        WP_CLI::error( 'Cannot symlink wpcomsh' );
2063    }
2064
2065    if ( ! chdir( WP_PLUGIN_DIR ) ) {
2066        WP_CLI::error( "Cannot switch to plugins directory '" . WP_PLUGIN_DIR . "'" );
2067    }
2068
2069    $managed_plugin_relative_path = "../../../../wordpress/plugins/$plugin_to_symlink/latest";
2070    if ( false === realpath( $managed_plugin_relative_path ) ) {
2071        WP_CLI::error( "'$plugin_to_symlink' is not a managed plugin" );
2072    }
2073
2074    $already_symlinked = false;
2075    if ( realpath( $plugin_to_symlink ) === realpath( $managed_plugin_relative_path ) ) {
2076        $already_symlinked = true;
2077    } elseif ( is_dir( $plugin_to_symlink ) ) {
2078        $permission_to_remove = false;
2079        if ( WP_CLI\Utils\get_flag_value( $assoc_args, 'remove-existing', false ) ) {
2080            $permission_to_remove = true;
2081        } elseif ( WP_CLI\Utils\get_flag_value( $assoc_args, 'remove-unmanaged', false ) ) {
2082            $permission_to_remove = true;
2083        } elseif ( wpcomsh_cli_confirm( "Plugin '$plugin_to_symlink' exists. Delete it and replace with symlink to managed plugin?" ) ) {
2084            $permission_to_remove = true;
2085        }
2086        if ( ! $permission_to_remove ) {
2087            exit( -1 );
2088        }
2089
2090        if ( is_link( $plugin_to_symlink ) ) {
2091            // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
2092            if ( ! unlink( $plugin_to_symlink ) ) {
2093                WP_CLI::error( "Failed to remove conflicting symlink '$plugin_to_symlink'" );
2094                exit( -1 );
2095            }
2096        } else {
2097            WP_CLI::runcommand(
2098                "--skip-plugins --skip-themes plugin delete '$plugin_to_symlink'",
2099                array(
2100                    'launch'     => false,
2101                    'exit_error' => true,
2102                )
2103            );
2104        }
2105    }
2106
2107    if ( $already_symlinked ) {
2108        WP_CLI::success( "Plugin '$plugin_to_symlink' is already symlinked" );
2109    } elseif ( symlink( $managed_plugin_relative_path, $plugin_to_symlink ) ) {
2110        WP_CLI::success( "Symlinked '$plugin_to_symlink' plugin" );
2111    } else {
2112        WP_CLI::error( "Failed to symlink '$plugin_to_symlink' plugin" );
2113        exit( -1 );
2114    }
2115
2116    $activate = WP_CLI\Utils\get_flag_value( $assoc_args, 'activate', false );
2117    if ( $activate ) {
2118
2119        // Invalidate cache so that the plugins can be read from the fs again.
2120        if ( ! $already_symlinked ) {
2121            wp_cache_delete( 'plugins', 'plugins' );
2122        }
2123
2124        WP_CLI::runcommand(
2125            "--skip-plugins --skip-themes plugin activate '$plugin_to_symlink'",
2126            array(
2127                'launch'     => false,
2128                'exit_error' => true,
2129            )
2130        );
2131    }
2132
2133    exit( 0 );
2134}
2135
2136/**
2137 * Symlinks a managed theme into the site's themes directory.
2138 *
2139 * ## OPTIONS
2140 *
2141 * <theme>
2142 * : The managed theme to symlink.
2143 *
2144 * [--remove-unmanaged]
2145 * : Deprecated. If there is an unmanaged directory in the way, remove it without asking.
2146 *
2147 * [--remove-existing]
2148 * : If there is an existing directory or different symlink in the way, remove it without asking.
2149 *
2150 * [--activate]
2151 * : Indicates that the symlinked theme should be activated
2152 *
2153 * @return never
2154 */
2155function wpcomsh_cli_theme_symlink( $args, $assoc_args = array() ) {
2156    WP_CLI::warning( 'This command is deprecated. Please use the `wpcomsh theme use-managed` command instead.' );
2157
2158    $theme_to_symlink = $args[0];
2159
2160    $themes_dir = get_theme_root();
2161    if ( ! chdir( $themes_dir ) ) {
2162        WP_CLI::error( "Cannot switch to themes directory '$themes_dir'" );
2163    }
2164
2165    $candidate_managed_theme_paths = array(
2166        // NOTE: pub and premium themes don't have nested `latest`and version directories.
2167        "../../../../wordpress/themes/pub/$theme_to_symlink",
2168        "../../../../wordpress/themes/premium/$theme_to_symlink",
2169        // Consider root themes dir last because we want to favor WPCOM-managed things on WPCOM
2170        // See p9o2xV-1LC-p2#comment-5417
2171        "../../../../wordpress/themes/$theme_to_symlink/latest",
2172    );
2173
2174    $managed_theme_path = false;
2175    foreach ( $candidate_managed_theme_paths as $candidate_path ) {
2176        if ( false !== realpath( $candidate_path ) ) {
2177            $managed_theme_path = $candidate_path;
2178            break;
2179        }
2180    }
2181
2182    if ( false === $managed_theme_path ) {
2183        WP_CLI::error( "'$theme_to_symlink' is not a managed theme" );
2184    }
2185
2186    $already_symlinked = false;
2187    if ( realpath( $theme_to_symlink ) === realpath( $managed_theme_path ) ) {
2188        $already_symlinked = true;
2189    } elseif ( is_dir( $theme_to_symlink ) ) {
2190        $permission_to_remove = false;
2191        if ( WP_CLI\Utils\get_flag_value( $assoc_args, 'remove-existing', false ) ) {
2192            $permission_to_remove = true;
2193        } elseif ( WP_CLI\Utils\get_flag_value( $assoc_args, 'remove-unmanaged', false ) ) {
2194            $permission_to_remove = true;
2195        } elseif ( wpcomsh_cli_confirm( "Theme '$theme_to_symlink' exists. Delete it and replace with symlink to managed theme?" ) ) {
2196            $permission_to_remove = true;
2197        }
2198        if ( ! $permission_to_remove ) {
2199            exit( -1 );
2200        }
2201
2202        if ( is_link( $theme_to_symlink ) ) {
2203            // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
2204            if ( ! unlink( $theme_to_symlink ) ) {
2205                WP_CLI::error( "Failed to remove conflicting symlink '$theme_to_symlink'" );
2206                exit( -1 );
2207            }
2208        } else {
2209            WP_CLI::runcommand(
2210                "--skip-plugins --skip-themes theme delete '$theme_to_symlink'",
2211                array(
2212                    'launch'     => false,
2213                    'exit_error' => true,
2214                )
2215            );
2216        }
2217    }
2218
2219    if ( $already_symlinked ) {
2220        WP_CLI::success( "Theme '$theme_to_symlink' is already symlinked" );
2221    } elseif ( symlink( $managed_theme_path, $theme_to_symlink ) ) {
2222        WP_CLI::success( "Symlinked '$theme_to_symlink' theme" );
2223    } else {
2224        WP_CLI::error( "Failed to symlink '$theme_to_symlink' theme" );
2225        exit( -1 );
2226    }
2227
2228    $activate = WP_CLI\Utils\get_flag_value( $assoc_args, 'activate', false );
2229    if ( $activate ) {
2230        WP_CLI::runcommand(
2231            "--skip-plugins --skip-themes theme activate '$theme_to_symlink'",
2232            array(
2233                'launch'     => false,
2234                'exit_error' => true,
2235            )
2236        );
2237    }
2238
2239    exit( 0 );
2240}
2241
2242/**
2243 * Makes the site live to the public.
2244 */
2245function wpcomsh_cli_launch_site() {
2246    WP_CLI::success( "If you're reading this, you should visit automattic.com/jobs and apply to join the fun, mention this command." );
2247}
2248
2249// Cleanup via WP-Cron event.
2250add_action( WPCOMSH_CLI_DEACTIVATED_PLUGIN_RECORD_CLEANUP_JOB, 'wpcomsh_cli_remove_expired_from_deactivation_record' );
2251
2252if ( ! defined( 'WP_CLI' ) || true !== WP_CLI ) {
2253    // We aren't running in a WP-CLI context, so there is nothing more to do.
2254    return;
2255}
2256
2257// Force WordPress to always output English at the command line.
2258WP_CLI::add_wp_hook(
2259    'pre_option_WPLANG',
2260    function () {
2261        return 'en_US';
2262    }
2263);
2264
2265// Maintain a record of deactivated plugins so that they can be reactivated by the reactivate-user-plugins command.
2266add_action( 'deactivated_plugin', 'wpcomsh_cli_remember_plugin_deactivation' );
2267add_action( 'activated_plugin', 'wpcomsh_cli_forget_plugin_deactivation' );
2268
2269WP_CLI::add_command( 'wpcomsh', 'WPCOMSH_CLI_Commands' );
2270WP_CLI::add_command( 'wpcomsh plugin verify-checksums', 'Checksum_Plugin_Command_WPCOMSH' );
2271WP_CLI::add_command( 'plugin symlink', 'wpcomsh_cli_plugin_symlink' );
2272WP_CLI::add_command( 'theme symlink', 'wpcomsh_cli_theme_symlink' );
2273WP_CLI::add_command( 'launch-site', 'wpcomsh_cli_launch_site' );
2274
2275add_action(
2276    'plugins_loaded',
2277    function () {
2278        if ( class_exists( 'Atomic_Platform_Managed_Software_Commands' ) ) {
2279            WP_CLI::add_command(
2280                'wpcomsh plugin use-managed',
2281                array( 'Atomic_Platform_Managed_Software_Commands', 'use_managed_plugin' )
2282            );
2283            WP_CLI::add_command(
2284                'wpcomsh plugin use-unmanaged',
2285                array( 'Atomic_Platform_Managed_Software_Commands', 'use_unmanaged_plugin' )
2286            );
2287            WP_CLI::add_command(
2288                'wpcomsh theme use-managed',
2289                array( 'Atomic_Platform_Managed_Software_Commands', 'use_managed_theme' )
2290            );
2291            WP_CLI::add_command(
2292                'wpcomsh theme use-unmanaged',
2293                array( 'Atomic_Platform_Managed_Software_Commands', 'use_unmanaged_theme' )
2294            );
2295        }
2296    }
2297);