Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
58.68% covered (warning)
58.68%
419 / 714
24.66% covered (danger)
24.66%
18 / 73
CRAP
0.00% covered (danger)
0.00%
0 / 1
Staging
58.68% covered (warning)
58.68%
419 / 714
24.66% covered (danger)
24.66%
18 / 73
4952.73
0.00% covered (danger)
0.00%
0 / 1
 __construct
68.29% covered (warning)
68.29%
28 / 41
0.00% covered (danger)
0.00%
0 / 1
1.03
 confirm_jetpack_safe_mode
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 initialize_staging_app
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 register_staging_assets
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
30
 loadTextDomain
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getInstance
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getConfig
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getConfigValue
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getProductionDir
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 withdrawCompatLockPublication
97.14% covered (success)
97.14%
34 / 35
0.00% covered (danger)
0.00%
0 / 1
7
 resolveProductionTablePrefix
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
9.86
 compatLockValueMatchesToken
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 getProductionUrl
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getStagingDir
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStagingUrl
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCreationDate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getEnvironment
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getProductionScreenshotUrl
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStagingScreenshotUrl
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isEnvironment
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isStaging
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 isProduction
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 stagingExists
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 cloneProductionToStaging
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 compatibilityCheck
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 createStaging
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 deployToProduction
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getDeployStatus
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 advanceContentCopy
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
2.50
 getContentCopyStatus
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getContentCopyController
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 runContentDeployVerb
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 randomToken
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
3.19
 recordDeployResult
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 runContentCopyCron
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 maybeScheduleContentCopyCron
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 scheduleContentCopyCron
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 unscheduleContentCopyCron
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getDeployCommandForType
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 isDeployCommand
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDeployResultPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDeployLockPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 acquireDeployLock
52.94% covered (warning)
52.94%
9 / 17
0.00% covered (danger)
0.00%
0 / 1
7.61
 releaseDeployLock
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isDeployLockHeld
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 isDeployInProgress
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
7.39
 writeDeployResult
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 readDeployResult
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 parseLogLineTimestamp
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 getDeployStatusFromLog
88.89% covered (warning)
88.89%
24 / 27
0.00% covered (danger)
0.00%
0 / 1
11.17
 getDeploySuccessLogStep
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getDeploySuccessMessage
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getDeployCommandStatus
68.75% covered (warning)
68.75%
22 / 32
0.00% covered (danger)
0.00%
0 / 1
18.16
 startAsyncDeploy
66.67% covered (warning)
66.67%
20 / 30
0.00% covered (danger)
0.00%
0 / 1
2.15
 spawnDetachedStagingScript
55.00% covered (warning)
55.00%
11 / 20
0.00% covered (danger)
0.00%
0 / 1
7.28
 executeStagingScript
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 deleteAuthToken
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 persist_auth_token
27.78% covered (danger)
27.78%
5 / 18
0.00% covered (danger)
0.00%
0 / 1
6.39
 getScriptEnvironment
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 logAuthTokenState
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
6.01
 auth_token_row
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 parseStagingScriptResponse
77.27% covered (warning)
77.27%
17 / 22
0.00% covered (danger)
0.00%
0 / 1
13.69
 destroyStaging
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 switchTo
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 check_bootstrap
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 resync_object_cache
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 runCommand
78.05% covered (warning)
78.05%
96 / 123
0.00% covered (danger)
0.00%
0 / 1
39.52
 add_log_admin_page
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 render_log_admin_page
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
110
 clean_log
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 run_staging_health_check
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 should_run_health_check_on_this_request
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
42
 render_staging_repair_notice
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2namespace NewfoldLabs\WP\Module\Staging;
3
4use NewfoldLabs\WP\ModuleLoader\Container;
5use NewfoldLabs\WP\Module\Staging\Copy\ContentCopyController;
6use function NewfoldLabs\WP\ModuleLoader\container;
7
8/**
9 * This class adds staging functionality.
10 **/
11class Staging {
12
13    /**
14     * Dependency injection container.
15     *
16     * @var Container
17     */
18    protected $container;
19
20    /**
21     * Slug used for the Staging module's admin page.
22     *
23     * @var string
24     */
25    const PAGE_SLUG = 'nfd-staging';
26
27    /**
28     * Seconds the staging auth token stays valid for.
29     *
30     * @var int
31     */
32    const AUTH_TOKEN_TTL = 300;
33
34    /**
35     * Seconds a running deploy result may block new work without a live lock.
36     *
37     * @var int
38     */
39    const DEPLOY_JOB_TTL = 1800;
40
41    /**
42     * WP-cron hook that advances an in-progress stepped content copy.
43     *
44     * @var string
45     */
46    const CONTENT_COPY_CRON_HOOK = 'nfd_staging_advance_content_copy';
47
48    /**
49     * Seconds between content-copy cron reschedules while a job is still running.
50     *
51     * @var int
52     */
53    const CONTENT_COPY_CRON_INTERVAL = 20;
54
55
56    /**
57     * Constructor.
58     *
59     * @param Container $container The module container.
60     */
61    public function __construct( Container $container ) {
62        $this->container = $container;
63
64        // Module functionality goes here
65        add_action(
66            'rest_api_init',
67            function () {
68                $instance = new StagingApi( $this->container );
69                $instance->register_routes();
70            }
71        );
72        add_action( 'wp_loaded', array( StagingMenu::class, 'init' ), 100 );
73
74        // WP-cron fallback that advances an in-progress stepped content copy and reschedules itself
75        // until the job finishes, so a closed browser (no more REST polls) still completes a deploy.
76        add_action( self::CONTENT_COPY_CRON_HOOK, array( $this, 'runContentCopyCron' ) );
77
78        // Mark Safe Mode as confirmed so the banner never re-appears
79        add_action( 'init', array( $this, 'confirm_jetpack_safe_mode' ), 20 );
80
81        // add isStaging as computed value to container
82        $this->container->set(
83            'isStaging',
84            $this->container->computed(
85                function () {
86                    return $this->isStaging();
87                }
88            )
89        );
90
91        // add CLI commands
92        add_action(
93            'cli_init',
94            function () {
95                \WP_CLI::add_command(
96                    'newfold staging',
97                    'NewfoldLabs\WP\Module\Staging\StagingCLI',
98                    array(
99                        'shortdesc' => 'Operations for Newfold staging.',
100                        'longdesc'  => 'Internal commands to handle staging environment.' .
101                                        PHP_EOL . 'Subcommands: create, clone, destroy, sso_staging, deploy, deploy_files,' .
102                                        ' deploy_db, deploy_files_db, save_state, restore_state, sso_production',
103                    )
104                );
105            }
106        );
107
108        add_action( 'init', array( __CLASS__, 'loadTextDomain' ), 100 );
109        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'initialize_staging_app' ) );
110
111        add_action( 'admin_menu', array( $this, 'add_log_admin_page' ) );
112
113        add_action( 'init', array( $this, 'clean_log' ) );
114
115        add_action( 'admin_init', array( $this, 'run_staging_health_check' ), 5 );
116        add_action( 'admin_notices', array( $this, 'render_staging_repair_notice' ) );
117
118        new Constants( $container );
119    }
120
121    /**
122     * Confirms Jetpack Safe Mode programmatically to suppress the banner.
123     *
124     * When a staging site is detected, this method sets the internal Jetpack option
125     * `safe_mode_confirmed` to `true`, which tells Jetpack that the user has already
126     * acknowledged Safe Mode. This prevents repeated prompts or blocked rendering on admin pages.
127     *
128     * @return void
129     */
130    public function confirm_jetpack_safe_mode() {
131        if ( $this->isStaging() && class_exists( 'Jetpack_Options' ) ) {
132            \Jetpack_Options::update_option( 'safe_mode_confirmed', true );
133        }
134    }
135
136    /**
137     * Initializes the Staging module by registering and enqueuing its assets.
138     *
139     * @return void
140     */
141    public static function initialize_staging_app() {
142        self::register_staging_assets();
143    }
144
145    /**
146     * Registers and enqueues the JavaScript and CSS assets for the Staging module.
147     *
148     * @return void
149     */
150    public static function register_staging_assets() {
151        $build_dir  = NFD_STAGING_BUILD_DIR;
152        $build_url  = NFD_STAGING_BUILD_URL;
153        $asset_file = $build_dir . '/staging/bundle.asset.php';
154
155        if ( is_readable( $asset_file ) ) {
156            $asset = include_once $asset_file;
157
158            wp_register_script(
159                self::PAGE_SLUG,
160                $build_url . '/staging/bundle.js',
161                $asset['dependencies'],
162                $asset['version'],
163                true
164            );
165
166            wp_register_style(
167                self::PAGE_SLUG,
168                $build_url . '/staging/staging.min.css',
169                array(),
170                $asset['version']
171            );
172        }
173
174        $screen = \get_current_screen();
175        if (
176            isset( $screen->id ) &&
177            (
178                false !== strpos( $screen->id, self::PAGE_SLUG ) ||
179                false !== strpos( $screen->id, container()->plugin()->id )
180            )
181        ) {
182            wp_enqueue_script( self::PAGE_SLUG );
183            wp_enqueue_style( self::PAGE_SLUG );
184        }
185    }
186
187    /**
188     * Load text domain for Module
189     *
190     * @return void
191     */
192    public static function loadTextDomain() {
193        \load_plugin_textdomain(
194            'wp-module-staging',
195            false,
196            dirname( plugin_basename( NFD_STAGING_DIR ) ) . '/' . basename( NFD_STAGING_DIR ) . '/languages'
197        );
198    }
199
200    /**
201     * Get an instance of this class.
202     *
203     * @return Staging
204     */
205    public static function getInstance() {
206        return new self( container() );
207    }
208
209    /**
210     * Get the staging configuration.
211     *
212     * @param bool $cache Whether or not to hit the cached config on this function call.
213     *
214     * @return array
215     */
216    public function getConfig( $cache = true ) {
217        static $config;
218
219        if ( ! isset( $config ) || false === $cache ) {
220            $config = get_option( 'staging_config', array() );
221        }
222
223        return $config;
224    }
225
226    /**
227     * Get a specific staging configuration value.
228     *
229     * Allowed keys:
230     *  - production_dir
231     *  - production_url
232     *  - staging_dir
233     *  - staging_url
234     *  - creation_date
235     *
236     * @param string $key     Configuration name.
237     * @param string $std Return default value if key doesn't exist.
238     *
239     * @return string
240     */
241    public function getConfigValue( $key, $std = '' ) {
242        $config = $this->getConfig();
243
244        return isset( $config[ $key ] ) ? $config[ $key ] : $std;
245    }
246
247    /**
248     * Get the production directory
249     *
250     * @return string
251     */
252    public function getProductionDir() {
253        $parsed = StagingPath::parse_staging_from_abspath( ABSPATH );
254        if ( null !== $parsed ) {
255            return $parsed['production_dir'];
256        }
257
258        return $this->getConfigValue( 'production_dir', ABSPATH );
259    }
260
261    /**
262     * Withdraw this job's publication on the cross-version compat channel (lib/.staging's
263     * LEGACY_LOCK_NAME / nfd_staging_lock transient).
264     *
265     * lib/.staging writes that transient with `wp transient set --path=<production_dir>` while
266     * skipping the object-cache drop-in, so it always lives as `_transient_nfd_staging_lock` in
267     * production's options table. Production and staging share the same MySQL database under
268     * different table prefixes, so we query production's options table directly via $wpdb rather
269     * than get_option() (which would hit staging's prefix and/or a persistent object cache).
270     *
271     * @param string $token Lock token being abandoned; must match the published value.
272     *
273     * @return void
274     */
275    public function withdrawCompatLockPublication( $token ) {
276        global $wpdb;
277
278        if ( '' === $token || ! ( $wpdb instanceof \wpdb ) ) {
279            return;
280        }
281
282        $prefix        = $this->resolveProductionTablePrefix();
283        $options_table = $prefix . 'options';
284        $option_name   = '_transient_' . ContentCopyController::COMPAT_CHANNEL_TRANSIENT;
285        $timeout_name  = '_transient_timeout_' . ContentCopyController::COMPAT_CHANNEL_TRANSIENT;
286
287        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
288        $value = $wpdb->get_var(
289            $wpdb->prepare(
290                "SELECT option_value FROM `{$options_table}` WHERE option_name = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
291                $option_name
292            )
293        );
294
295        if ( ! is_string( $value ) || ! $this->compatLockValueMatchesToken( $value, $token ) ) {
296            return;
297        }
298
299        // Guard the DELETE by exact option_value so a concurrent takeover between SELECT and DELETE is untouched.
300        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
301        $deleted = $wpdb->query(
302            $wpdb->prepare(
303                "DELETE FROM `{$options_table}` WHERE option_name = %s AND option_value = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
304                $option_name,
305                $value
306            )
307        );
308
309        if ( ! $deleted ) {
310            return;
311        }
312
313        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
314        $wpdb->query(
315            $wpdb->prepare(
316                "DELETE FROM `{$options_table}` WHERE option_name = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
317                $timeout_name
318            )
319        );
320
321        if ( $prefix === $wpdb->prefix ) {
322            wp_cache_delete( $option_name, 'options' );
323            wp_cache_delete( $timeout_name, 'options' );
324            wp_cache_delete( 'alloptions', 'options' );
325            wp_cache_delete( 'notoptions', 'options' );
326        }
327    }
328
329    /**
330     * Resolve production's MySQL table prefix from staging_config, production's wp-config.php, or $wpdb.
331     *
332     * @return string Validated SQL-safe table prefix.
333     */
334    private function resolveProductionTablePrefix() {
335        global $wpdb;
336
337        $config = $this->getConfig( false );
338        if ( ! empty( $config['production_prefix'] )
339            && is_string( $config['production_prefix'] )
340            && preg_match( '/^[A-Za-z0-9_]+$/', $config['production_prefix'] ) ) {
341            return $config['production_prefix'];
342        }
343
344        $prod_wp_config = untrailingslashit( $this->getProductionDir() ) . '/wp-config.php';
345        if ( is_readable( $prod_wp_config ) ) {
346            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
347            $contents = file_get_contents( $prod_wp_config );
348            if ( is_string( $contents )
349                && preg_match( '/^[ \t]*\$table_prefix[ \t]*=[ \t]*([\'"])([A-Za-z0-9_]+)\1/m', $contents, $matches ) ) {
350                return $matches[2];
351            }
352        }
353
354        return preg_match( '/^[A-Za-z0-9_]+$/', (string) $wpdb->prefix ) ? (string) $wpdb->prefix : 'wp_';
355    }
356
357    /**
358     * Whether a compat-channel value belongs to the given lock token.
359     *
360     * @param string $value Published transient value.
361     * @param string $token Expected token.
362     *
363     * @return bool
364     */
365    private function compatLockValueMatchesToken( $value, $token ) {
366        $parts = explode( ':', $value );
367        return count( $parts ) >= 3 && 'active' === $parts[0] && hash_equals( (string) $parts[2], $token );
368    }
369
370    /**
371     * Get the production URL
372     *
373     * @return string
374     */
375    public function getProductionUrl() {
376        $parsed = StagingPath::parse_staging_from_url( site_url() );
377        if ( null !== $parsed ) {
378            return $parsed['production_url'];
379        }
380
381        return $this->getConfigValue( 'production_url', site_url() );
382    }
383
384    /**
385     * Get the staging directory
386     *
387     * @return string
388     */
389    public function getStagingDir() {
390        return $this->getConfigValue( 'staging_dir' );
391    }
392
393    /**
394     * Get the staging URL
395     *
396     * @return string
397     */
398    public function getStagingUrl() {
399        return $this->getConfigValue( 'staging_url' );
400    }
401
402    /**
403     * Get the staging creation date
404     *
405     * @return string
406     */
407    public function getCreationDate() {
408        return $this->getConfigValue( 'creation_date' );
409    }
410
411    /**
412     * Get the name of the current environment.
413     *
414     * @return string|false
415     */
416    public function getEnvironment() {
417        return get_option( 'staging_environment', 'production' );
418    }
419
420    /**
421     * Get production screenshot URL.
422     *
423     * @return string
424     */
425    public function getProductionScreenshotUrl() {
426        return '';
427    }
428
429    /**
430     * Get staging screenshot URL.
431     *
432     * @return string
433     */
434    public function getStagingScreenshotUrl() {
435        return '';
436    }
437
438    /**
439     * Check if the current environment matches a specific value.
440     *
441     * @param string $env Environment name (production or staging).
442     *
443     * @return bool
444     */
445    public function isEnvironment( $env ) {
446        return $this->getEnvironment() === $env;
447    }
448
449    /**
450     * Check if the current environment is staging.
451     *
452     * @return bool
453     */
454    public function isStaging() {
455        if ( StagingPath::is_staging_abspath( ABSPATH ) ) {
456            return true;
457        }
458
459        if ( defined( 'WP_ENVIRONMENT_TYPE' ) && 'staging' === WP_ENVIRONMENT_TYPE ) {
460            return true;
461        }
462
463        return $this->isEnvironment( 'staging' );
464    }
465
466    /**
467     * Check if the current environment is production.
468     *
469     * @return bool
470     */
471    public function isProduction() {
472        if ( StagingPath::is_staging_abspath( ABSPATH ) ) {
473            return false;
474        }
475
476        return $this->isEnvironment( 'production' );
477    }
478
479    /**
480     * Check if the staging exists
481     *
482     * @return bool
483     */
484    public function stagingExists() {
485        $stagingDir = $this->getStagingDir();
486        return ! empty( $stagingDir ) && file_exists( $stagingDir );
487    }
488
489    /**
490     * Clone production environment to staging.
491     *
492     * @return array|\WP_Error
493     */
494    public function cloneProductionToStaging() {
495        if ( ! $this->isProduction() ) {
496            return new \WP_Error(
497                'invalid_environment',
498                __( 'Cloning can only be done from the production environment.', 'wp-module-staging' )
499            );
500        }
501
502        $bootstrap = $this->check_bootstrap( $this->getProductionDir() );
503        if ( is_wp_error( $bootstrap ) ) {
504            return $bootstrap;
505        }
506
507        /*
508         * Before move_content_dirs(), clone runs WP-CLI against the existing staging directory
509         * with --skip-plugins --skip-themes. Regular plugins are replaced by production's copy
510         * and are not booted, so a full probe would block the clone that repairs them. Must-use
511         * plugins, drop-ins, and wp-config.php still load and can fail those commands.
512         */
513        $bootstrap = $this->check_bootstrap( $this->getStagingDir(), false );
514        if ( is_wp_error( $bootstrap ) ) {
515            return $bootstrap;
516        }
517
518        return $this->runCommand( 'clone' );
519    }
520
521    /**
522     * Run a compatibility check to see if the environment supports staging.
523     *
524     * @return array|\WP_Error
525     */
526    public function compatibilityCheck() {
527        return $this->runCommand( 'compat_check' );
528    }
529
530    /**
531     * Create a staging environment.
532     *
533     * @return array|\WP_Error
534     */
535    public function createStaging() {
536        if ( $this->stagingExists() ) {
537            return new \WP_Error(
538                'environment_exists',
539                __( 'Staging environment already exists!', 'wp-module-staging' )
540            );
541        }
542
543        $bootstrap = $this->check_bootstrap( $this->getProductionDir() );
544        if ( is_wp_error( $bootstrap ) ) {
545            return $bootstrap;
546        }
547
548        return $this->runCommand( 'create' );
549    }
550
551    /**
552     * Deploy changes from staging to production.
553     *
554     * Long-running deploys run asynchronously so the HTTP response returns before
555     * Cloudflare or other proxies hit their read timeout (often 120 seconds).
556     *
557     * @param string $type Deployment type. One of `db`, `files`, or `all`.
558     *
559     * @return array|\WP_Error
560     */
561    public function deployToProduction( $type = 'all' ) {
562        $command = $this->getDeployCommandForType( $type );
563
564        /*
565         * deploy_files runs `wp core version` and `wp core download`, which WP-CLI runs before
566         * WordPress loads, and every other call passes --skip-plugins --skip-themes. A regular
567         * plugin cannot fail the deploy. Must-use plugins, drop-ins, and wp-config.php still can.
568         * deploy_db never boots those either, so it is not probed.
569         */
570        if ( 'deploy_db' !== $command ) {
571            $bootstrap = $this->check_bootstrap( $this->getStagingDir(), false );
572            if ( is_wp_error( $bootstrap ) ) {
573                return $bootstrap;
574            }
575        }
576
577        return $this->runCommand( $command );
578    }
579
580    /**
581     * Get the current status of a deploy job (for polling after async start).
582     *
583     * @param string $type Deployment type. One of `db`, `files`, or `all`.
584     *
585     * @return array
586     */
587    public function getDeployStatus( $type = 'all' ) {
588        return $this->getDeployCommandStatus( $this->getDeployCommandForType( $type ) );
589    }
590
591    /**
592     * Advance one bounded unit of the stepped content-copy deploy and return progress.
593     *
594     * This is the new chunked deploy path: each call runs at most one bash bookend verb OR one
595     * CopyEngine burst, so a poll driver (the REST advance route or the cron fallback) makes steady
596     * progress without any single request running long. The legacy `/staging/deploy` path is
597     * untouched.
598     *
599     * @param string $type    Copy type. Only `deploy_files` is supported for now.
600     * @param bool   $restart When true, discard any prior job and start fresh (the UI sends this on the
601     *                        first advance of a new deploy).
602     *
603     * @return array|\WP_Error
604     */
605    public function advanceContentCopy( $type = 'deploy_files', $restart = false ) {
606        $controller = $this->getContentCopyController( $type );
607        if ( is_wp_error( $controller ) ) {
608            return $controller;
609        }
610
611        $result = $controller->advance( (bool) $restart );
612        $this->maybeScheduleContentCopyCron( $result );
613
614        return $result;
615    }
616
617    /**
618     * Report the current phase/progress of the stepped content-copy deploy without advancing it.
619     *
620     * @param string $type Copy type. Only `deploy_files` is supported for now.
621     *
622     * @return array|\WP_Error
623     */
624    public function getContentCopyStatus( $type = 'deploy_files' ) {
625        $controller = $this->getContentCopyController( $type );
626        if ( is_wp_error( $controller ) ) {
627            return $controller;
628        }
629
630        return $controller->status();
631    }
632
633    /**
634     * Build a content-copy controller for a validated copy type.
635     *
636     * @param string $type Copy type.
637     *
638     * @return ContentCopyController|\WP_Error
639     */
640    protected function getContentCopyController( $type ) {
641        if ( 'deploy_files' !== $type ) {
642            return new \WP_Error(
643                'invalid_copy_type',
644                __( 'Unsupported content copy type.', 'wp-module-staging' )
645            );
646        }
647
648        return new ContentCopyController( $this );
649    }
650
651    /**
652     * Run a single content-deploy bash verb through the normal command path.
653     *
654     * Exposed so ContentCopyController (a separate class in the Copy namespace) can invoke the
655     * `deploy_content_setup` / `deploy_content_finalize` verbs while keeping runCommand's token
656     * handshake, cache resync, NFD_STAGING_ENV, and shell-escaping. These verbs are bounded and
657     * synchronous (not isDeployCommand()), so runCommand runs them via executeStagingScript.
658     *
659     * @param string $verb       One of `deploy_content_setup`, `deploy_content_core`,
660     *                           `deploy_content_finalize`.
661     * @param string $lock_token The job's staging-lock token, passed to the script as a command
662     *                           argument so all three verbs share one hold on the staging mutex. See
663     *                           lock_check in lib/.staging and StagingCommandLock.
664     *
665     * @return array|\WP_Error
666     */
667    public function runContentDeployVerb( $verb, $lock_token = '' ) {
668        if ( ! in_array( $verb, array( 'deploy_content_setup', 'deploy_content_core', 'deploy_content_finalize' ), true ) ) {
669            return new \WP_Error(
670                'invalid_command',
671                __( 'Invalid staging CLI command.', 'wp-module-staging' )
672            );
673        }
674
675        /*
676         * Checked here as well as in the script: a malformed token would forge extra fields in the
677         * colon-delimited lock value, and the script refuses the verb outright without one, which
678         * would surface as an opaque failure mid-deploy.
679         */
680        $lock = new StagingCommandLock( $this->getProductionDir() );
681        if ( ! $lock->isValidToken( $lock_token ) ) {
682            return new \WP_Error(
683                'invalid_lock_token',
684                __( 'Missing or invalid staging lock token.', 'wp-module-staging' )
685            );
686        }
687
688        return $this->runCommand( $verb, array( $lock_token ) );
689    }
690
691    /**
692     * A random 32-character alphanumeric token, from a generator that never calls into WordPress.
693     *
694     * Deliberately NOT wp_generate_password(), which ends in
695     * `apply_filters( 'random_password', ... )`. That filter is hooked in the wild -- Jetpack among
696     * others -- and a hook can both change the value and do work of its own, including outbound HTTP.
697     * A filtered value carrying symbols would fail the `[A-Za-z0-9]` shape both the lock value and the
698     * shell argument require, so every staging operation on such a site would fail for a reason
699     * nothing explains; and minting a local identifier has no business reaching out to anything.
700     *
701     * @return string
702     */
703    public static function randomToken() {
704        try {
705            return bin2hex( random_bytes( 16 ) );
706        } catch ( \Throwable $t ) {
707            return substr( hash( 'sha256', uniqid( (string) getmypid(), true ) . microtime() ), 0, 32 );
708        }
709    }
710
711    /**
712     * Persist a deploy progress/result payload to the shared result file.
713     *
714     * Lets ContentCopyController reuse the exact file the legacy `/staging/deploy` GET status reads,
715     * so the stepped path's running/success/error states surface through the existing poller too.
716     *
717     * @param array $data Result payload.
718     *
719     * @return void
720     */
721    public function recordDeployResult( array $data ) {
722        $this->writeDeployResult( $data );
723    }
724
725    /**
726     * Cron callback: advance an in-progress content copy and reschedule until it finishes.
727     *
728     * @return void
729     */
730    public function runContentCopyCron() {
731        $controller = new ContentCopyController( $this );
732        if ( ! $controller->isInProgress() ) {
733            // No job, or it already finished; nothing to reschedule.
734            return;
735        }
736
737        $result = $controller->advance();
738        $this->maybeScheduleContentCopyCron( $result );
739    }
740
741    /**
742     * Schedule or unschedule the content-copy cron fallback based on a job result.
743     *
744     * @param array|\WP_Error $result Latest advance()/status() envelope.
745     *
746     * @return void
747     */
748    protected function maybeScheduleContentCopyCron( $result ) {
749        $running = is_array( $result ) && isset( $result['status'] ) && 'running' === $result['status'];
750
751        if ( $running ) {
752            $this->scheduleContentCopyCron();
753        } else {
754            $this->unscheduleContentCopyCron();
755        }
756    }
757
758    /**
759     * Schedule the next content-copy cron tick if one is not already queued.
760     *
761     * @return void
762     */
763    protected function scheduleContentCopyCron() {
764        if ( ! wp_next_scheduled( self::CONTENT_COPY_CRON_HOOK ) ) {
765            wp_schedule_single_event( time() + self::CONTENT_COPY_CRON_INTERVAL, self::CONTENT_COPY_CRON_HOOK );
766        }
767    }
768
769    /**
770     * Remove any queued content-copy cron tick (job done, errored, or gone).
771     *
772     * @return void
773     */
774    protected function unscheduleContentCopyCron() {
775        $timestamp = wp_next_scheduled( self::CONTENT_COPY_CRON_HOOK );
776        if ( $timestamp ) {
777            wp_unschedule_event( $timestamp, self::CONTENT_COPY_CRON_HOOK );
778        }
779    }
780
781    /**
782     * Map REST deploy type to the staging CLI command name.
783     *
784     * @param string $type Deployment type.
785     *
786     * @return string
787     */
788    protected function getDeployCommandForType( $type ) {
789        switch ( $type ) {
790            case 'db':
791                return 'deploy_db';
792            case 'files':
793                return 'deploy_files';
794            default:
795                return 'deploy_files_db';
796        }
797    }
798
799    /**
800     * Whether the command is a long-running deploy operation.
801     *
802     * @param string $command CLI command name.
803     *
804     * @return bool
805     */
806    protected function isDeployCommand( $command ) {
807        return in_array( $command, array( 'deploy_db', 'deploy_files', 'deploy_files_db' ), true );
808    }
809
810    /**
811     * Path to the JSON file that stores async deploy progress/result.
812     *
813     * @return string
814     */
815    protected function getDeployResultPath() {
816        return trailingslashit( $this->getProductionDir() ) . 'nfd-private/nfd-staging-deploy-result.json';
817    }
818
819    /**
820     * Path to the file used for the deploy process lock.
821     *
822     * @return string
823     */
824    protected function getDeployLockPath() {
825        return trailingslashit( $this->getProductionDir() ) . 'nfd-private/nfd-staging-deploy.lock';
826    }
827
828    /**
829     * Acquire the exclusive deploy lock.
830     *
831     * @return resource|false|\WP_Error Lock handle on success, false when another deploy owns it.
832     */
833    protected function acquireDeployLock() {
834        $path = $this->getDeployLockPath();
835        $dir  = dirname( $path );
836        if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
837            return new \WP_Error(
838                'deploy_lock_unavailable',
839                __( 'Unable to create the deployment lock directory.', 'wp-module-staging' )
840            );
841        }
842
843        $handle = fopen( $path, 'c' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
844        if ( false === $handle ) {
845            return new \WP_Error(
846                'deploy_lock_unavailable',
847                __( 'Unable to open the deployment lock file.', 'wp-module-staging' )
848            );
849        }
850
851        if ( ! flock( $handle, LOCK_EX | LOCK_NB ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_flock
852            fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
853            return false;
854        }
855
856        return $handle;
857    }
858
859    /**
860     * Release an acquired deploy lock.
861     *
862     * @param resource $handle Lock handle.
863     *
864     * @return void
865     */
866    protected function releaseDeployLock( $handle ) {
867        if ( is_resource( $handle ) ) {
868            flock( $handle, LOCK_UN ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_flock
869            fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
870        }
871    }
872
873    /**
874     * Whether another process currently owns the deploy lock.
875     *
876     * @return bool
877     */
878    protected function isDeployLockHeld() {
879        $handle = $this->acquireDeployLock();
880        if ( is_wp_error( $handle ) ) {
881            return true;
882        }
883        if ( false === $handle ) {
884            return true;
885        }
886
887        $this->releaseDeployLock( $handle );
888        return false;
889    }
890
891    /**
892     * Whether a deploy is already running.
893     *
894     * @return bool
895     */
896    protected function isDeployInProgress() {
897        if ( $this->isDeployLockHeld() ) {
898            return true;
899        }
900
901        // A stepped content copy has no PHP deploy flock and can legitimately run far longer than
902        // DEPLOY_JOB_TTL (that is the whole point), so consult it directly. Without this, a big stepped
903        // deploy older than the TTL would let a legacy /staging/deploy start on top of it and rm -rf the
904        // engine's in-progress `-new` tree.
905        $content_copy = new ContentCopyController( $this );
906        if ( $content_copy->isInProgress() ) {
907            return true;
908        }
909
910        $result = $this->readDeployResult();
911        if ( is_array( $result ) && isset( $result['status'] ) && 'running' === $result['status'] ) {
912            $started_at = isset( $result['started_at'] ) ? (int) $result['started_at'] : 0;
913            return $started_at > ( time() - self::DEPLOY_JOB_TTL );
914        }
915
916        return false;
917    }
918
919    /**
920     * Persist async deploy progress or result to disk.
921     *
922     * @param array $data Result payload.
923     */
924    protected function writeDeployResult( array $data ) {
925        $path = $this->getDeployResultPath();
926        $dir  = dirname( $path );
927        if ( ! is_dir( $dir ) ) {
928            wp_mkdir_p( $dir );
929        }
930        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
931        file_put_contents( $path, wp_json_encode( $data ) );
932    }
933
934    /**
935     * Read async deploy progress or result from disk.
936     *
937     * @return array|null
938     */
939    protected function readDeployResult() {
940        $path = $this->getDeployResultPath();
941        if ( ! file_exists( $path ) ) {
942            return null;
943        }
944        $raw = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
945        if ( ! $raw ) {
946            return null;
947        }
948        $data = json_decode( $raw, true );
949        return is_array( $data ) ? $data : null;
950    }
951
952    /**
953     * Parse the timestamp prefix from a staging log line.
954     *
955     * @param string $line Log line.
956     *
957     * @return int|null Unix timestamp, or null when not parseable.
958     */
959    protected function parseLogLineTimestamp( $line ) {
960        if ( preg_match( '/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/', $line, $matches ) ) {
961            $parsed = strtotime( $matches[1] . ' UTC' );
962            return $parsed ? $parsed : null;
963        }
964
965        return null;
966    }
967
968    /**
969     * Resolve deploy status from the staging log (fallback when the HTTP request timed out).
970     *
971     * @param string   $command          CLI command name.
972     * @param int|null $since_timestamp  Only consider log lines at or after this Unix time.
973     *
974     * @return array|null
975     */
976    protected function getDeployStatusFromLog( $command, $since_timestamp = null ) {
977        $log_file = trailingslashit( $this->getProductionDir() ) . 'nfd-private/nfd-staging.log';
978        if ( ! file_exists( $log_file ) ) {
979            return null;
980        }
981
982        $success_step = $this->getDeploySuccessLogStep( $command );
983        if ( ! $success_step ) {
984            return null;
985        }
986
987        $lines = file( $log_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
988        if ( ! is_array( $lines ) ) {
989            return null;
990        }
991
992        $lines = array_reverse( $lines );
993        foreach ( $lines as $line ) {
994            $line_time = $this->parseLogLineTimestamp( $line );
995            if ( $since_timestamp && $line_time && $line_time < ( $since_timestamp - 30 ) ) {
996                continue;
997            }
998
999            // The shell emits this marker only when an operation actually exits through error().
1000            if ( false !== strpos( $line, '[ERROR] [operation_failed]' ) ) {
1001                return array(
1002                    'status'  => 'error',
1003                    'command' => $command,
1004                    'message' => __( 'Deployment failed. Check the staging log for details.', 'wp-module-staging' ),
1005                );
1006            }
1007            if ( false !== strpos( $line, '[SUCCESS]' ) && false !== strpos( $line, '[' . $success_step . ']' ) ) {
1008                return array(
1009                    'status'  => 'success',
1010                    'command' => $command,
1011                    'message' => $this->getDeploySuccessMessage( $command ),
1012                );
1013            }
1014        }
1015
1016        return null;
1017    }
1018
1019    /**
1020     * Log step name that indicates a successful deploy for the given command.
1021     *
1022     * @param string $command CLI command name.
1023     *
1024     * @return string
1025     */
1026    protected function getDeploySuccessLogStep( $command ) {
1027        $map = array(
1028            'deploy_files'    => 'deploy_files:end',
1029            'deploy_db'       => 'deploy_db:end',
1030            'deploy_files_db' => 'deploy_files_db:end',
1031        );
1032
1033        return isset( $map[ $command ] ) ? $map[ $command ] : '';
1034    }
1035
1036    /**
1037     * User-facing success message for a completed deploy command.
1038     *
1039     * @param string $command CLI command name.
1040     *
1041     * @return string
1042     */
1043    protected function getDeploySuccessMessage( $command ) {
1044        $messages = array(
1045            'deploy_files'    => __( 'Files deployed successfully.', 'wp-module-staging' ),
1046            'deploy_db'       => __( 'Database deployed successfully.', 'wp-module-staging' ),
1047            'deploy_files_db' => __( 'Files and Database deployed successfully.', 'wp-module-staging' ),
1048        );
1049
1050        return isset( $messages[ $command ] ) ? $messages[ $command ] : __( 'Deployment completed successfully.', 'wp-module-staging' );
1051    }
1052
1053    /**
1054     * Resolve the current status of a deploy command.
1055     *
1056     * @param string $command CLI command name.
1057     *
1058     * @return array
1059     */
1060    protected function getDeployCommandStatus( $command ) {
1061        $result         = $this->readDeployResult();
1062        $since          = is_array( $result ) && ! empty( $result['started_at'] ) ? (int) $result['started_at'] : null;
1063        $same_command   = is_array( $result ) && isset( $result['command'] ) && $result['command'] === $command;
1064        $stored_running = $same_command && isset( $result['status'] ) && 'running' === $result['status'];
1065
1066        if ( $same_command && ! $stored_running ) {
1067            return $result;
1068        }
1069
1070        /*
1071         * The log is consulted before the in-progress checks below, not after. executeStagingScript()
1072         * blocks this process in exec() for the whole deploy, so PHP-FPM can terminate the worker on
1073         * its request timeout while the shell runs on to completion: the result file is left saying
1074         * "running" and the handler that would have corrected it is gone. Reading the shell's own
1075         * terminal marker first is what stops a finished deploy from polling until DEPLOY_JOB_TTL and
1076         * then being reported as a failure that never happened.
1077         */
1078        $log_status = $this->getDeployStatusFromLog( $command, $since );
1079        if ( $log_status ) {
1080            $this->writeDeployResult(
1081                $since ? array_merge( $log_status, array( 'started_at' => $since ) ) : $log_status
1082            );
1083            return $log_status;
1084        }
1085
1086        if ( $this->isDeployInProgress() ) {
1087            return array(
1088                'status'  => 'running',
1089                'command' => $command,
1090                'message' => __( 'Deployment in progress. This may take several minutes.', 'wp-module-staging' ),
1091            );
1092        }
1093
1094        if ( $stored_running ) {
1095            $result = array(
1096                'status'     => 'error',
1097                'command'    => $command,
1098                'started_at' => $since,
1099                'message'    => __( 'The deployment stopped before it completed. Please try again.', 'wp-module-staging' ),
1100            );
1101            $this->writeDeployResult( $result );
1102            return $result;
1103        }
1104
1105        return array(
1106            'status'  => 'running',
1107            'command' => $command,
1108            'message' => __( 'Deployment in progress. This may take several minutes.', 'wp-module-staging' ),
1109        );
1110    }
1111
1112    /**
1113     * Start a deploy in a detached shell process and return immediately.
1114     *
1115     * Blocking exec() on shutdown left this worker waiting on the whole deploy, so PHP-FPM's
1116     * request timeout could kill it while the script was still running. The child inherits
1117     * NFD_STAGING_ENV and NFD_DEPLOY_* and records its own result. The flock cannot be handed
1118     * to that child, so it is released here; isDeployInProgress() then treats the running
1119     * result file as the in-progress signal until the script finishes.
1120     *
1121     * @param string   $script            Path to the staging shell script.
1122     * @param string   $command_name      CLI command name (e.g. deploy_db).
1123     * @param string   $shell_command     Escaped CLI argument string for exec().
1124     * @param string   $auth_token_value  Exact option value written for this command.
1125     * @param resource $deploy_lock       Acquired deploy lock handle.
1126     *
1127     * @return array|\WP_Error
1128     */
1129    protected function startAsyncDeploy( $script, $command_name, $shell_command, $auth_token_value, $deploy_lock ) {
1130        $started_at = time();
1131
1132        $this->writeDeployResult(
1133            array(
1134                'status'     => 'running',
1135                'command'    => $command_name,
1136                'started_at' => $started_at,
1137                'message'    => __( 'Deployment in progress. This may take several minutes.', 'wp-module-staging' ),
1138            )
1139        );
1140
1141        try {
1142            putenv( 'NFD_DEPLOY_COMMAND=' . $command_name ); // phpcs:ignore
1143            putenv( 'NFD_DEPLOY_STARTED_AT=' . $started_at ); // phpcs:ignore
1144
1145            $spawned = $this->spawnDetachedStagingScript( $script, $shell_command, $auth_token_value );
1146            if ( is_wp_error( $spawned ) ) {
1147                $this->deleteAuthToken( $auth_token_value );
1148                $this->writeDeployResult(
1149                    array(
1150                        'status'     => 'error',
1151                        'command'    => $command_name,
1152                        'started_at' => $started_at,
1153                        'message'    => $spawned->get_error_message(),
1154                    )
1155                );
1156                return $spawned;
1157            }
1158        } finally {
1159            $this->releaseDeployLock( $deploy_lock );
1160        }
1161
1162        return array(
1163            'status'     => 'running',
1164            'command'    => $command_name,
1165            'started_at' => $started_at,
1166            'message'    => __( 'Deployment in progress. This may take several minutes.', 'wp-module-staging' ),
1167        );
1168    }
1169
1170    /**
1171     * Launch the staging script in the background so this request does not wait on it.
1172     *
1173     * stdin is taken from /dev/null so the worker is not kept alive as a parent of a
1174     * job that still has an open TTY. nohup (with a subshell fallback) survives SIGHUP
1175     * when this request ends. The token is left in place: auth_action() consumes it,
1176     * and deleting it here would race the child.
1177     *
1178     * @param string $script           Path to the staging shell script.
1179     * @param string $command          Escaped CLI argument string.
1180     * @param string $auth_token_value Exact option value written for this command.
1181     *
1182     * @return int|\WP_Error Spawned PID, or an error when the process did not start.
1183     */
1184    protected function spawnDetachedStagingScript( $script, $command, $auth_token_value ) {
1185        do_action( 'newfold_staging_command', $command ); // bh_staging_command
1186
1187        $this->logAuthTokenState( $auth_token_value );
1188
1189        $quoted_script = escapeshellarg( $script );
1190        $launch        = "nohup {$quoted_script} {$command} >/dev/null 2>&1 </dev/null & echo \$!";
1191
1192        $output = array();
1193        $status = 0;
1194        exec( $launch, $output, $status ); // phpcs:ignore
1195
1196        $pid = isset( $output[0] ) ? (int) $output[0] : 0;
1197        if ( $pid < 1 ) {
1198            $launch = "( {$quoted_script} {$command} >/dev/null 2>&1 </dev/null & echo \$! )";
1199            $output = array();
1200            $status = 0;
1201            exec( $launch, $output, $status ); // phpcs:ignore
1202            $pid = isset( $output[0] ) ? (int) $output[0] : 0;
1203        }
1204
1205        if ( $pid < 1 ) {
1206            return new \WP_Error(
1207                'error_response',
1208                __( 'Unable to start the deployment process.', 'wp-module-staging' )
1209            );
1210        }
1211
1212        return $pid;
1213    }
1214
1215    /**
1216     * Run the staging shell script and parse its JSON stdout.
1217     *
1218     * @param string $script            Path to the staging shell script.
1219     * @param string $command_name      CLI command name (e.g. deploy_db), used to gate the compat_check exemptions below.
1220     * @param string $command           Escaped CLI argument string.
1221     * @param string $auth_token_value  Exact option value written for this command.
1222     *
1223     * @return array|\WP_Error
1224     */
1225    protected function executeStagingScript( $script, $command_name, $command, $auth_token_value ) {
1226        do_action( 'newfold_staging_command', $command ); // bh_staging_command
1227
1228        $this->logAuthTokenState( $auth_token_value );
1229
1230        $output      = array();
1231        $exit_status = 0;
1232        exec( "{$script} {$command}", $output, $exit_status ); // phpcs:ignore
1233
1234        // The script wrote options with the cache bypassed, so put the two back in sync.
1235        if ( 'compat_check' !== $command_name ) {
1236            $this->resync_object_cache();
1237        }
1238
1239        /*
1240         * auth_action() consumes the token, but only on the paths that reach it. compat_check exits
1241         * before authenticating, and the script can die earlier than that. Unlike the transient this
1242         * replaced, an option does not expire on its own, so an uncollected token would sit in
1243         * wp_options in plain text and travel into every database export.
1244         */
1245        $this->deleteAuthToken( $auth_token_value );
1246
1247        return $this->parseStagingScriptResponse( $output, $exit_status );
1248    }
1249
1250    /**
1251     * Delete only the auth token written for the command that just finished.
1252     *
1253     * @param string $auth_token_value Exact stored option value.
1254     *
1255     * @return void
1256     */
1257    protected function deleteAuthToken( $auth_token_value ) {
1258        global $wpdb;
1259
1260        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1261        $deleted = $wpdb->query(
1262            $wpdb->prepare(
1263                "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1264                'staging_auth_token',
1265                $auth_token_value
1266            )
1267        );
1268
1269        if ( $deleted ) {
1270            wp_cache_delete( 'staging_auth_token', 'options' );
1271            wp_cache_delete( 'notoptions', 'options' );
1272        }
1273    }
1274
1275    /**
1276     * Store the single-use auth token and prove it reached the database.
1277     *
1278     * update_option() cannot be trusted on its own here. When a persistent cache still holds a
1279     * value whose row lib/.staging has already deleted, update_option() reads that phantom from
1280     * cache, concludes the row exists, issues an UPDATE matching nothing and returns false having
1281     * written no row at all. The script then finds no token and the command fails as "Unable to
1282     * authenticate the action.", with nothing on either side recording that a write was dropped.
1283     *
1284     * The row is therefore read straight off the database, bypassing get_option() and whatever
1285     * cache sits in front of it. A failed first attempt is retried once after evicting the keys
1286     * that produce the phantom, so a cache in this state is repaired rather than merely reported.
1287     *
1288     * @param string $token Token to store.
1289     *
1290     * @return string|false Stored "<token>.<expiry>" value, or false when the write did not land.
1291     */
1292    protected function persist_auth_token( $token ) {
1293        global $wpdb;
1294
1295        // Options have no TTL of their own, so the expiry rides along in the value.
1296        $value = $token . '.' . ( time() + self::AUTH_TOKEN_TTL );
1297
1298        update_option( 'staging_auth_token', $value, false );
1299
1300        if ( $this->auth_token_row() === $value ) {
1301            return $value;
1302        }
1303
1304        /*
1305         * The cache claimed a row that is not there, so stop asking it. Evict the keys that produce
1306         * the phantom and write the row directly, because update_option() would just consult the
1307         * same cache again and drop the write a second time. Evicting afterwards leaves get_option()
1308         * agreeing with what is now on disk.
1309         *
1310         * 'no' rather than 'off' for autoload: WordPress 6.6 renamed the values but still reads the
1311         * old ones, and the module supports installs from before the rename.
1312         */
1313        $this->resync_object_cache();
1314
1315        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1316        $wpdb->delete( $wpdb->options, array( 'option_name' => 'staging_auth_token' ) );
1317        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1318        $wpdb->insert(
1319            $wpdb->options,
1320            array(
1321                'option_name'  => 'staging_auth_token',
1322                'option_value' => $value,
1323                'autoload'     => 'no',
1324            )
1325        );
1326
1327        wp_cache_delete( 'staging_auth_token', 'options' );
1328        wp_cache_delete( 'notoptions', 'options' );
1329
1330        return $this->auth_token_row() === $value ? $value : false;
1331    }
1332
1333    /**
1334     * Which install this request is running in, for lib/.staging to read the token from.
1335     *
1336     * The script used to infer this from its own working directory, testing whether the path
1337     * contained "/staging/". That is wrong on hosts where the staging site's index.php is a symlink
1338     * into production's core: PHP resolves the symlink, so a request to /staging/<id>/index.php
1339     * starts in the production directory. The script then read production's staging_auth_token
1340     * while this request had written the token to the staging database, and every deploy driven
1341     * from the staging dashboard failed as "Unable to authenticate the action.".
1342     *
1343     * ABSPATH is authoritative because it names the install whose database update_option() just
1344     * wrote to, which is exactly what the script has to read back.
1345     *
1346     * @param array       $config  staging_config values.
1347     * @param string|null $abspath Install root to classify. Defaults to ABSPATH.
1348     *
1349     * @return string Either 'staging' or 'production'.
1350     */
1351    protected function getScriptEnvironment( array $config, $abspath = null ) {
1352        $abspath = StagingPath::normalize_trailing_slash( null === $abspath ? ABSPATH : $abspath );
1353
1354        if ( StagingPath::is_staging_abspath( $abspath ) ) {
1355            return 'staging';
1356        }
1357
1358        if ( ! empty( $config['staging_dir'] )
1359            && StagingPath::normalize_trailing_slash( $config['staging_dir'] ) === $abspath ) {
1360            return 'staging';
1361        }
1362
1363        return 'production';
1364    }
1365
1366    /**
1367     * Record whether PHP can read the token row immediately before the script does.
1368     *
1369     * auth_action() reporting a missing token has several possible causes that look identical from
1370     * the shell: the row was never written, it was written to a different database or prefix than
1371     * the one WP-CLI resolves from --path, or something consumed it in between. Writing PHP's own
1372     * view into the same log, on the same clock, is what separates them. The stored value itself
1373     * is never written: the log is also shown on the Tools page.
1374     *
1375     * @param string $auth_token_value Exact option value written for this command.
1376     *
1377     * @return void
1378     */
1379    protected function logAuthTokenState( $auth_token_value ) {
1380        global $wpdb;
1381
1382        $log_file = trailingslashit( $this->getProductionDir() ) . 'nfd-private/nfd-staging.log';
1383        $dir      = dirname( $log_file );
1384        if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
1385            return;
1386        }
1387
1388        $row     = $this->auth_token_row();
1389        $message = sprintf(
1390            '%s [INFO] [auth_token] PHP wrote token into %s prefix=%s; row readable: %s',
1391            gmdate( 'Y-m-d H:i:s' ),
1392            defined( 'DB_NAME' ) ? DB_NAME : '<undefined>',
1393            $wpdb->prefix,
1394            null === $row ? 'no' : ( $row === $auth_token_value ? 'yes' : 'different value' )
1395        );
1396
1397        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
1398        file_put_contents( $log_file, $message . PHP_EOL, FILE_APPEND );
1399    }
1400
1401    /**
1402     * Read the stored auth token straight from the options table.
1403     *
1404     * get_option() is deliberately not used: the whole point of the caller is to find out what is
1405     * actually on disk, and get_option() answers from the cache that may be lying.
1406     *
1407     * @return string|null Stored value, or null when the row does not exist.
1408     */
1409    private function auth_token_row() {
1410        global $wpdb;
1411
1412        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
1413        return $wpdb->get_var(
1414            $wpdb->prepare(
1415                "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1416                'staging_auth_token'
1417            )
1418        );
1419    }
1420
1421    /**
1422     * Decode JSON stdout from the staging shell script.
1423     *
1424     * @param array $output      Lines of stdout captured from the staging script.
1425     * @param int   $exit_status Exit status of the staging script.
1426     *
1427     * @return array|\WP_Error
1428     */
1429    protected function parseStagingScriptResponse( $output, $exit_status = 0 ) {
1430        $output = (array) $output;
1431
1432        /*
1433         * Take the last line that actually parses as one of our responses rather than assuming the
1434         * script's own output is the last thing on stdout. A drop-in, a PHP notice or any other
1435         * stray write would otherwise be handed to json_decode() and turn a working run into the
1436         * generic failure below. Every response the script emits carries a "status" key.
1437         */
1438        $response = null;
1439        for ( $i = count( $output ) - 1; $i >= 0; $i-- ) {
1440            $decoded = json_decode( trim( $output[ $i ] ), true );
1441            if ( is_array( $decoded ) && isset( $decoded['status'] ) ) {
1442                $response = $decoded;
1443                break;
1444            }
1445        }
1446
1447        if ( null === $response ) {
1448            $last_line  = end( $output );
1449            $cloudflare = $last_line ? json_decode( trim( $last_line ), true ) : null;
1450            if ( is_array( $cloudflare ) && ! empty( $cloudflare['cloudflare_error'] ) ) {
1451                return new \WP_Error(
1452                    'origin_timeout',
1453                    __( 'The deployment is still running. Please wait and check again shortly.', 'wp-module-staging' ),
1454                    array( 'status' => 524 )
1455                );
1456            }
1457
1458            return new \WP_Error( 'json_decode', __( 'Something gone wrong, please get in touch with our support.', 'wp-module-staging' ) );
1459        }
1460
1461        /*
1462         * Some commands print their success line and then keep working (deploy_db cleans up its
1463         * backup and runs the exit trap afterwards). If the script died during that tail we would
1464         * otherwise find the success line and report a run that did not finish as successful, so
1465         * the exit status has the final say.
1466         */
1467        if ( 0 !== $exit_status && 'error' !== $response['status'] ) {
1468            return new \WP_Error( 'error_response', __( 'The staging operation did not complete, please get in touch with our support.', 'wp-module-staging' ) );
1469        }
1470
1471        /*
1472         * Array access, not object access: json_decode() above is associative, so an object-style
1473         * test could never match and error messages from the script would be silently returned to
1474         * the caller as if they were successful responses.
1475         */
1476        if ( isset( $response['status'], $response['message'] ) && 'error' === $response['status'] ) {
1477            return new \WP_Error( 'error_response', $response['message'] );
1478        }
1479
1480        return $response;
1481    }
1482
1483    /**
1484     * Destroy the staging environment.
1485     *
1486     * @return array|\WP_Error
1487     */
1488    public function destroyStaging() {
1489        if ( ! $this->isProduction() ) {
1490            return new \WP_Error(
1491                'invalid_environment',
1492                __( 'You must switch to the production environment before destroying staging.', 'wp-module-staging' )
1493            );
1494        }
1495
1496        return $this->runCommand( 'destroy' );
1497    }
1498
1499    /**
1500     * Switch to a different environment.
1501     *
1502     * @param string $env     Environment name (staging or production).
1503     * @param int    $user_id User ID to login as.
1504     *
1505     * @return array|\WP_Error
1506     */
1507    public function switchTo( $env, $user_id = 0 ) {
1508        if ( ! $user_id ) {
1509            $user_id = get_current_user_id();
1510        }
1511        if ( $this->isEnvironment( $env ) ) {
1512            return new \WP_Error(
1513                'invalid_environment',
1514                __( 'Switch to an environment you are already in, you cannot.', 'wp-module-staging' )
1515            );
1516        }
1517
1518        /*
1519         * `wp newfold sso` boots the destination with every active plugin. Check that install
1520         * before the script turns an empty link into "Unable to create SSO link".
1521         */
1522        $target    = ( 'staging' === $env ) ? $this->getStagingDir() : $this->getProductionDir();
1523        $bootstrap = $this->check_bootstrap( $target );
1524        if ( is_wp_error( $bootstrap ) ) {
1525            return $bootstrap;
1526        }
1527
1528        if ( 'staging' === $env ) {
1529            return $this->runCommand( 'sso_staging', array( $user_id ) );
1530        }
1531
1532        return $this->runCommand( 'sso_production', array( $user_id ) );
1533    }
1534
1535    /**
1536     * Stop an operation whose directory cannot bootstrap under WP-CLI.
1537     *
1538     * Runs before runCommand(), so a fatal never writes staging_config or the auth token.
1539     * An empty path fails open and leaves the script to report the missing directory.
1540     *
1541     * @param string $path            Production or staging directory the operation will boot.
1542     * @param bool   $load_extensions Whether that boot loads regular plugins and themes.
1543     * @return true|\WP_Error
1544     */
1545    protected function check_bootstrap( $path, $load_extensions = true ) {
1546        if ( ! is_string( $path ) || '' === $path ) {
1547            return true;
1548        }
1549
1550        return ( new StagingBootstrapCheck() )->check( $path, $load_extensions );
1551    }
1552
1553    /**
1554     * Drop the cached copies of every option lib/.staging touches.
1555     *
1556     * The script runs WP-CLI with the object-cache drop-in skipped so a broken drop-in cannot kill
1557     * it, which means its option writes reach the database without evicting the copies a live
1558     * persistent cache is still serving. That is not merely stale data. update_option() here reads
1559     * the cached value, concludes the row still exists, issues an UPDATE that matches nothing and
1560     * returns false, so the write is dropped and the option can never be changed again until the
1561     * key is evicted.
1562     *
1563     * The script cannot always fix this itself: in the case that motivated all of this the drop-in
1564     * works for web requests but dies under WP-CLI, so the CLI has no route to the cache while this
1565     * request does.
1566     *
1567     * Known limit: this only reaches the install it runs in. The Newfold drop-in namespaces its
1568     * keys by table prefix, so a deploy, which is driven from the staging site, cannot evict
1569     * production's keys from here. lib/.staging makes a best effort pass over the production path
1570     * for that direction; on a site whose drop-in is broken under WP-CLI both are no-ops and
1571     * production's cached options stay stale until they are evicted by other means.
1572     *
1573     * @return void
1574     */
1575    protected function resync_object_cache() {
1576        if ( ! wp_using_ext_object_cache() ) {
1577            return;
1578        }
1579
1580        $keys = array(
1581            'staging_auth_token',
1582            'staging_config',
1583            'staging_environment',
1584            'nfd_coming_soon',
1585            'alloptions',
1586            'notoptions',
1587        );
1588
1589        foreach ( $keys as $key ) {
1590            wp_cache_delete( $key, 'options' );
1591        }
1592    }
1593
1594    /**
1595     * Execute a staging CLI command.
1596     *
1597     * @param string     $command CLI command to be run.
1598     * @param array|null $args    CLI command arguments to be passed.
1599     *
1600     * @return array|\WP_Error
1601     */
1602    protected function runCommand( $command, $args = null ) {
1603        // $command is rebuilt into the escaped argument string below, so keep the name to test against.
1604        $command_name = $command;
1605
1606        /*
1607         * Before anything reads or writes an option below. A previous run may have left the cache
1608         * out of step with the database, and update_option() silently drops writes in that state,
1609         * including the staging_config write further down. compat_check is exempt because it writes
1610         * nothing and consuming plugins may call it often enough for the eviction cost to matter.
1611         */
1612        if ( 'compat_check' !== $command_name ) {
1613            $this->resync_object_cache();
1614        }
1615
1616        $allowedCommands = array(
1617            'clone'                   => true,
1618            'compat_check'            => true,
1619            'create'                  => true,
1620            'deploy_db'               => true,
1621            'deploy_files'            => true,
1622            'deploy_files_db'         => true,
1623            'deploy_content_setup'    => true,
1624            'deploy_content_core'     => true,
1625            'deploy_content_finalize' => true,
1626            'destroy'                 => true,
1627            'sso_production'          => true,
1628            'sso_staging'             => true,
1629        );
1630
1631        // Check if command is allowed
1632        if ( ! array_key_exists( $command_name, $allowedCommands ) ) {
1633            return new \WP_Error(
1634                'invalid_command',
1635                __( 'Invalid staging CLI command.', 'wp-module-staging' )
1636            );
1637        }
1638
1639        /*
1640         * A stepped content copy builds the production `-new` tree over many short requests. While it is
1641         * in progress, any OTHER staging command would mutate or `rm -rf` the very trees the copy is
1642         * reading from (staging) or writing to (production/`-new`): a concurrent `clone`/`create` runs
1643         * move_content_dirs and wipes staging mid-copy, a `destroy` tears it down, a legacy `deploy_*`
1644         * promotes on top of a half-built tree. Files then vanish mid-copy and a partial tree can be
1645         * finalized over production. Refuse every command except the copy's OWN bounded bookend verbs
1646         * (which are how the in-progress job makes progress) until it finishes. Fail closed.
1647         */
1648        $stepped_copy_verbs = array( 'deploy_content_setup', 'deploy_content_core', 'deploy_content_finalize' );
1649        if ( ! in_array( $command_name, $stepped_copy_verbs, true ) ) {
1650            $content_copy = new ContentCopyController( $this );
1651            if ( $content_copy->isInProgress() ) {
1652                return new \WP_Error(
1653                    'content_copy_in_progress',
1654                    __( 'A content deploy is already in progress. Wait for it to finish before running another staging operation.', 'wp-module-staging' )
1655                );
1656            }
1657        }
1658
1659        $config = $this->getConfig();
1660
1661        // If config is empty, then we are creating a staging environment.
1662        if ( empty( $config ) || 'create' === $command_name ) {
1663
1664            $uniqueId = wp_rand( 1000, 9999 );
1665
1666            $config = array(
1667                'creation_date'  => gmdate( 'M j, Y' ),
1668                'production_dir' => ABSPATH,
1669                'production_url' => get_option( 'siteurl' ),
1670                'staging_dir'    => ABSPATH . 'staging/' . $uniqueId,
1671                'staging_url'    => get_option( 'siteurl' ) . '/staging/' . $uniqueId,
1672            );
1673
1674            update_option( 'staging_config', $config );
1675
1676        }
1677
1678        // Not wp_generate_password: see Staging::randomToken for why a `random_password` filter has no
1679        // business in a single-use local identifier.
1680        $token = self::randomToken();
1681
1682        /*
1683         * Handed to lib/.staging, which runs WP-CLI with the object-cache drop-in skipped so a
1684         * broken drop-in cannot corrupt the JSON this method parses back. That makes
1685         * wp_using_ext_object_cache() false in the script while it is true in this request, and
1686         * set_transient() follows that flag: the token would be written to the object cache here
1687         * and looked for in wp_options there. An option is database backed on both sides.
1688         *
1689         * The expiry is carried in the value because options have no TTL. auth_action() in the
1690         * script splits on the "." and deletes the option as soon as it reads it, which preserves
1691         * the single-use behaviour the transient gave us.
1692         */
1693
1694        $plugin_basename = explode( '/', container()->plugin()->basename );
1695
1696        $plugin_slug = is_array( $plugin_basename ) && ! empty( $plugin_basename ) ? $plugin_basename[0] : null;
1697
1698        $command_args = array(
1699            $command_name,
1700            $token,
1701            $config['production_dir'],
1702            $config['staging_dir'],
1703            $config['production_url'],
1704            $config['staging_url'],
1705            get_current_user_id(),
1706            container()->plugin()->id,
1707            $plugin_slug,
1708            container()->plugin()->name,
1709        );
1710
1711        if ( $args && is_array( $args ) ) {
1712            $command_args = array_merge( $command_args, array_values( $args ) );
1713        }
1714
1715        $shell_command = implode( ' ', array_map( 'escapeshellarg', $command_args ) );
1716
1717        // Check for invalid characters
1718        $invalidChars = array( ';', '&', '|' );
1719        foreach ( $invalidChars as $char ) {
1720            if ( false !== strpos( $shell_command, $char ) ) {
1721                return new \WP_Error(
1722                    'invalid_character',
1723                    // translators: Invalid character that was entered
1724                    sprintf( __( 'Invalid character (%s) in command.', 'wp-module-staging' ), $char )
1725                );
1726            }
1727        }
1728
1729        $script = container()->plugin()->dir . 'vendor/newfold-labs/wp-module-staging/lib/.staging';
1730
1731        $disabled_functions = explode( ',', ini_get( 'disable_functions' ) );
1732        if ( is_array( $disabled_functions ) && in_array( 'exec', array_map( 'trim', $disabled_functions ), true ) ) {
1733            return new \WP_Error( 'error_response', __( 'Unable to execute script (disabled_function).', 'wp-module-staging' ) );
1734        }
1735
1736        // Verify staging script file permissions using WP_Filesystem API
1737        global $wp_filesystem;
1738
1739        if ( ! function_exists( 'request_filesystem_credentials' ) ) {
1740            require_once ABSPATH . 'wp-admin/includes/file.php';
1741        }
1742
1743        $creds = request_filesystem_credentials( '', '', false, false, null );
1744
1745        if ( false === $creds ) {
1746            return new \WP_Error( 'error_response', __( 'Filesystem credentials required.', 'wp-module-staging' ) );
1747        }
1748
1749        if ( ! WP_Filesystem( $creds ) ) {
1750            return new \WP_Error( 'error_response', __( 'Unable to initialize WP Filesystem.', 'wp-module-staging' ) );
1751        }
1752
1753        if ( $wp_filesystem->exists( $script ) ) {
1754            if ( $wp_filesystem->is_writable( $script ) ) {
1755                $wp_filesystem->chmod( $script, 0755 );
1756            } else {
1757                return new \WP_Error( 'error_response', __( 'Unable to execute script (permission error).', 'wp-module-staging' ) );
1758            }
1759        }
1760
1761        putenv( 'PATH=' . getenv( 'PATH' ) . PATH_SEPARATOR . '/usr/local/bin' ); // phpcs:ignore
1762
1763        // Read by lib/.staging to pick the install it authenticates against. Set before every exec,
1764        // including a detached deploy, whose child inherits this process environment.
1765        putenv( 'NFD_STAGING_ENV=' . $this->getScriptEnvironment( $config ) ); // phpcs:ignore
1766
1767        /*
1768         * Written last, once every check above has passed. Anything that returns before this point
1769         * never reaches the delete below and never runs the script that would consume the token,
1770         * so persisting it earlier would strand a live credential in wp_options.
1771         */
1772        $deploy_lock = null;
1773        if ( $this->isDeployCommand( $command_name ) ) {
1774            if ( $this->isDeployInProgress() ) {
1775                return array(
1776                    'status'  => 'running',
1777                    'command' => $command_name,
1778                    'message' => __( 'Deployment in progress. This may take several minutes.', 'wp-module-staging' ),
1779                );
1780            }
1781            $deploy_lock = $this->acquireDeployLock();
1782            if ( is_wp_error( $deploy_lock ) ) {
1783                return $deploy_lock;
1784            }
1785            if ( false === $deploy_lock ) {
1786                return array(
1787                    'status'  => 'running',
1788                    'command' => $command_name,
1789                    'message' => __( 'Deployment in progress. This may take several minutes.', 'wp-module-staging' ),
1790                );
1791            }
1792        } elseif ( $this->isDeployLockHeld() ) {
1793            return new \WP_Error(
1794                'staging_locked',
1795                __( 'A deployment is currently in progress. Please wait for it to finish.', 'wp-module-staging' )
1796            );
1797        }
1798
1799        $auth_token_value = $this->persist_auth_token( $token );
1800        if ( ! $auth_token_value ) {
1801            delete_option( 'staging_auth_token' );
1802            if ( $deploy_lock ) {
1803                $this->releaseDeployLock( $deploy_lock );
1804            }
1805
1806            return new \WP_Error(
1807                'auth_token_not_persisted',
1808                __( 'Unable to store the staging authentication token. A persistent object cache may be out of sync with the database, or the database is not accepting writes.', 'wp-module-staging' )
1809            );
1810        }
1811
1812        if ( $this->isDeployCommand( $command_name ) ) {
1813            return $this->startAsyncDeploy( $script, $command_name, $shell_command, $auth_token_value, $deploy_lock );
1814        }
1815
1816        $response = $this->executeStagingScript( $script, $command_name, $shell_command, $auth_token_value );
1817
1818        if ( is_wp_error( $response ) ) {
1819            return $response;
1820        }
1821
1822        // Do not wipe the log after a stepped content-copy bookend. These verbs are synchronous (so they
1823        // reach this non-deploy path), but finalize writes the forensic trail -- "-recovered" rollbacks,
1824        // kept-aside warnings -- that a support engineer needs after a failed/partial swap. The other
1825        // verbs run standalone, so clearing the log for them keeps it scoped to the latest operation.
1826        if ( 0 !== strpos( $command_name, 'deploy_content_' ) ) {
1827            wp_delete_file( ABSPATH . '/nfd-private/nfd-staging.log' );
1828        }
1829
1830        return $response;
1831    }
1832
1833    /**
1834     * Add the log admin page to the Tools menu.
1835     */
1836    public function add_log_admin_page() {
1837        $hook = add_submenu_page(
1838            'nfd-staging-log',
1839            __( 'Log Staging', 'wp-module-staging' ),
1840            '',
1841            'manage_options',
1842            'nfd-staging-log',
1843            array( $this, 'render_log_admin_page' )
1844        );
1845        remove_menu_page( $hook );
1846    }
1847
1848    /**
1849     * Render the log admin page.
1850     */
1851    public function render_log_admin_page() {
1852        if ( ! current_user_can( 'manage_options' ) ) {
1853            wp_die( esc_html__( "Don't have capabilities to access this page", 'wp-module-staging' ) );
1854        }
1855
1856        $log_file = $this->getProductionDir() . '/nfd-private/nfd-staging.log';
1857
1858        $logs        = array();
1859        $filter_date = isset( $_GET['log_date'] ) ? sanitize_text_field( $_GET['log_date'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1860        $per_page    = isset( $_GET['per_page'] ) ? max( 1, intval( $_GET['per_page'] ) ) : 30; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1861        $page        = isset( $_GET['paged'] ) ? max( 1, intval( $_GET['paged'] ) ) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1862
1863        if ( file_exists( $log_file ) ) {
1864            $lines = file( $log_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES );
1865            foreach ( $lines as $line ) {
1866                $log_date = substr( $line, 0, 19 );
1867                if ( $filter_date ) {
1868                    if ( strpos( $log_date, $filter_date ) === 0 ) {
1869                        $logs[] = $line;
1870                    }
1871                } else {
1872                    $logs[] = $line;
1873                }
1874            }
1875        }
1876
1877        $total_logs   = count( $logs );
1878        $total_pages  = $per_page > 0 ? ceil( $total_logs / $per_page ) : 1;
1879        $start        = ( $page - 1 ) * $per_page;
1880        $logs_to_show = array_slice( $logs, $start, $per_page );
1881        $instance     = $this;
1882
1883        include __DIR__ . '/../views/staging-log.php';
1884    }
1885
1886    /**
1887     * Clean up old log file if the plugin has been upgraded from an older version
1888     */
1889    public function clean_log() {
1890        if ( file_exists( ABSPATH . '/nfd-staging.log' ) ) {
1891            wp_delete_file( ABSPATH . '/nfd-staging.log' );
1892        }
1893    }
1894
1895    /**
1896     * Run staging health check on relevant admin requests.
1897     *
1898     * @return void
1899     */
1900    public function run_staging_health_check() {
1901        if ( ! is_admin() || ! current_user_can( 'manage_options' ) ) {
1902            return;
1903        }
1904
1905        if ( ! $this->should_run_health_check_on_this_request() ) {
1906            return;
1907        }
1908
1909        $health = new StagingHealthCheck( $this );
1910        if ( $health->maybe_repair() ) {
1911            $this->getConfig( false );
1912        }
1913    }
1914
1915    /**
1916     * Whether the current admin request should trigger a health check.
1917     *
1918     * @return bool
1919     */
1920    protected function should_run_health_check_on_this_request() {
1921        if ( isset( $_GET['page'] ) && container()->plugin()->id === $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1922            return true;
1923        }
1924
1925        if ( isset( $_GET['page'] ) && self::PAGE_SLUG === $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1926            return true;
1927        }
1928
1929        if ( StagingPath::is_staging_abspath( ABSPATH ) ) {
1930            return true;
1931        }
1932
1933        return false;
1934    }
1935
1936    /**
1937     * Show a one-time notice when auto-repair has run.
1938     *
1939     * @return void
1940     */
1941    public function render_staging_repair_notice() {
1942        if ( ! current_user_can( 'manage_options' ) ) {
1943            return;
1944        }
1945
1946        if ( ! get_transient( StagingHealthCheck::get_notice_transient_key() ) ) {
1947            return;
1948        }
1949
1950        delete_transient( StagingHealthCheck::get_notice_transient_key() );
1951
1952        printf(
1953            '<div class="notice notice-warning is-dismissible"><p>%s</p></div>',
1954            esc_html__( 'Staging configuration was automatically repaired.', 'wp-module-staging' )
1955        );
1956    }
1957}