$this->slug(), 'risk' => 'high', 'capability' => 'update_themes', 'summary' => 'Updates one theme safely: keeps a backup of the current version, checks the ' . 'site still responds after the update, and automatically restores the backup if the site ' . 'breaks. Always call with dry_run first; execution requires the confirm token from the ' . 'dry-run response.', 'annotations' => array( 'destructive' => true, 'idempotent' => false, ), 'example_input' => array( 'theme' => 'twentytwentyfour' ), 'input_schema' => array( 'type' => 'object', 'required' => array( 'theme' ), 'properties' => array( 'theme' => array( 'type' => 'string', 'description' => 'Theme directory / stylesheet slug (e.g. "twentytwentyfour").', ), ), ), ); } public function dry_run( $atb_input ) { $atb_context = $this->build_context( $atb_input ); $atb_refusal = self::preflight_refusal( $atb_context ); if ( null !== $atb_refusal ) { return $this->output( false, true, array_merge( array( 'refused' => true ), $atb_refusal ) ); } // The safety net must be proven BEFORE the token is issued (v1.5.2 F2). $atb_baseline = ATB_Site_Probe::baseline_refusal(); if ( null !== $atb_baseline ) { return $this->output( false, true, array_merge( array( 'refused' => true ), $atb_baseline ) ); } $atb_warnings = array(); if ( $atb_context['is_active'] || $atb_context['is_active_parent'] ) { $atb_warnings[] = 'this theme is loaded on every request — a broken update takes the whole site ' . 'down until the automatic restore puts the previous version back'; } return $this->output( false, true, array( 'theme' => $atb_context['stylesheet'], 'installed_version' => $atb_context['installed_version'], 'available_version' => $atb_context['available_version'], 'update_available' => true, // The risk signals for a theme update: the active theme and // the parent of an active child are both included on every // request, so a fatal in either breaks the whole site. 'active' => $atb_context['is_active'], 'active_parent' => $atb_context['is_active_parent'], ), $atb_warnings ); } public function run( $atb_input ) { // The confirm token was already verified by Agent_Toolbelt::execute(). $atb_context = $this->build_context( $atb_input ); $atb_refusal = self::preflight_refusal( $atb_context ); if ( null !== $atb_refusal ) { return $this->output( false, false, array_merge( array( 'refused' => true ), $atb_refusal ) ); } $atb_baseline = ATB_Site_Probe::baseline_refusal(); if ( null !== $atb_baseline ) { return $this->output( false, false, array_merge( array( 'refused' => true ), $atb_baseline ) ); } $atb_stylesheet = $atb_context['stylesheet']; $atb_warnings = array(); $this->load_upgrader_dependencies(); $atb_skin = new Automatic_Upgrader_Skin(); $atb_upgrader = new Theme_Upgrader( $atb_skin ); $atb_result = $atb_upgrader->upgrade( $atb_stylesheet ); if ( true !== $atb_result ) { // Nothing was installed (download/unpack/copy failed or the // transient lost the update). If install_package failed after // the backup was taken, core itself has already scheduled the // shutdown restore (temp_restores is populated on WP_Error). // Core's messages name absolute paths; redacted (v1.5.2 F1). $atb_error = is_wp_error( $atb_result ) ? Agent_Toolbelt::redact( $atb_result->get_error_message() ) : 'update failed'; return $this->output( false, false, array( 'theme' => $atb_stylesheet, 'previous_version' => $atb_context['installed_version'], 'error' => $atb_error, ), array_merge( array( 'theme update did not complete; the installed version was not replaced' ), array_map( array( 'Agent_Toolbelt', 'redact' ), (array) $atb_skin->get_upgrade_messages() ) ) ); } $atb_backup = array( 'slug' => $atb_stylesheet, 'src' => get_theme_root( $atb_stylesheet ), 'dir' => 'themes', ); // Take over the backup lifecycle: core must not delete it on // shutdown while our health probe is still ahead. remove_action( 'shutdown', array( $atb_upgrader, 'delete_temp_backup' ), 100 ); $this->atb_failsafe = array( 'upgrader' => $atb_upgrader, 'backup' => $atb_backup, 'theme' => $atb_stylesheet, ); add_action( 'shutdown', array( $this, 'failsafe_restore' ), 1 ); // The probe is the first request that includes the updated theme's // code (when it is the active theme or the active child's parent). $atb_probe = $this->health_probe(); $atb_output = array( 'theme' => $atb_stylesheet, 'previous_version' => $atb_context['installed_version'], 'new_version' => $atb_context['available_version'], 'health_check' => $atb_probe['passed'] ? 'passed' : 'failed', 'rolled_back' => false, ); if ( $atb_probe['passed'] ) { $this->disarm_failsafe(); $atb_deleted = $atb_upgrader->delete_temp_backup( array( $atb_backup ) ); if ( true !== $atb_deleted ) { $atb_warnings[] = 'temporary backup could not be deleted; core cleans it up within a day'; } return $this->output( true, false, $atb_output, $atb_warnings ); } // Health probe failed: put the previous version back. $atb_warnings = array_merge( $atb_warnings, $atb_probe['failures'] ); $atb_restored = $atb_upgrader->restore_temp_backup( array( $atb_backup ) ); $this->disarm_failsafe(); if ( true === $atb_restored ) { $atb_output['rolled_back'] = true; // Same cache rationale as update-plugin: the upgrade cleaned // the caches for the NEW version; the restore put the OLD // files back, so clean again or the theme headers and update // check keep describing the rolled-back version. if ( function_exists( 'wp_clean_themes_cache' ) ) { wp_clean_themes_cache( true ); } } else { $atb_warnings[] = 'automatic rollback failed — the new version is still installed; restore ' . $atb_stylesheet . ' manually from wp-content/upgrade-temp-backup/themes/'; } return $this->output( true, false, $atb_output, $atb_warnings ); } /** * Loads the wp-admin machinery the upgrade needs when running outside * wp-admin (abilities via REST/MCP, WP-CLI). class-wp-upgrader.php * pulls in every skin plus Theme_Upgrader itself. */ protected function load_upgrader_dependencies() { if ( ! class_exists( 'WP_Upgrader' ) ) { require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; } if ( ! function_exists( 'request_filesystem_credentials' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } if ( ! function_exists( 'wp_update_themes' ) ) { require_once ABSPATH . 'wp-includes/update.php'; } } /* ---------------------------------------------------------------- * Preflight: resolve the theme + the refusal matrix * -------------------------------------------------------------- */ /** * Gathers everything the refusal matrix needs from live WP. Kept * separate from preflight_refusal() so the matrix itself is pure and * unit-testable without WordPress. * * @param array $atb_input * @return array See preflight_refusal() for the consumed keys. */ protected function build_context( $atb_input ) { $atb_requested = ( is_array( $atb_input ) && isset( $atb_input['theme'] ) ) ? trim( (string) $atb_input['theme'] ) : ''; $atb_installed = array(); foreach ( wp_get_themes() as $atb_stylesheet => $atb_theme ) { $atb_installed[ $atb_stylesheet ] = (string) $atb_theme->get( 'Version' ); } $atb_stylesheet = ( '' !== $atb_requested && isset( $atb_installed[ $atb_requested ] ) ) ? $atb_requested : null; // Refresh update data so the answer reflects wp.org's current // state (core throttles this internally; same freshness the // wp-admin updates screen works with). if ( ! function_exists( 'wp_update_themes' ) ) { require_once ABSPATH . 'wp-includes/update.php'; } wp_update_themes(); $atb_updates = get_site_transient( 'update_themes' ); $atb_available = null; // Theme entries in the update transient are ARRAYS, not objects // (unlike update_plugins) — verified against Theme_Upgrader. if ( null !== $atb_stylesheet && is_object( $atb_updates ) && isset( $atb_updates->response[ $atb_stylesheet ]['new_version'] ) ) { $atb_available = (string) $atb_updates->response[ $atb_stylesheet ]['new_version']; } return array( 'requested' => $atb_requested, 'stylesheet' => $atb_stylesheet, 'installed_version' => ( null !== $atb_stylesheet ) ? $atb_installed[ $atb_stylesheet ] : null, 'available_version' => $atb_available, 'is_multisite' => is_multisite(), 'is_active' => ( null !== $atb_stylesheet ) && get_stylesheet() === $atb_stylesheet, 'is_active_parent' => ( null !== $atb_stylesheet ) && get_template() === $atb_stylesheet && get_stylesheet() !== $atb_stylesheet, ); } /** * The refusal matrix. Pure: decides only from the passed context. * `reason` is a machine code; `error` carries the human sentence. * * @param array $atb_context { * requested string Raw input. * stylesheet string|null Resolved stylesheet slug. * installed_version string|null * available_version string|null New version, when an update exists. * is_multisite bool * } * @return array|null { reason: string, error: string }, or null to proceed. */ public static function preflight_refusal( $atb_context ) { if ( ! empty( $atb_context['is_multisite'] ) ) { return array( 'reason' => 'multisite_unsupported', 'error' => 'multisite is not supported: updating themes network-wide is out of scope for this ability', ); } if ( '' === $atb_context['requested'] ) { return array( 'reason' => 'missing_theme_input', 'error' => 'missing required input: theme (directory / stylesheet slug)', ); } if ( null === $atb_context['stylesheet'] ) { return array( 'reason' => 'theme_not_installed', 'error' => 'theme not installed: ' . $atb_context['requested'], ); } if ( null === $atb_context['available_version'] ) { return array( 'reason' => 'no_update_available', 'error' => 'no update available for ' . $atb_context['stylesheet'] . ' (installed version ' . $atb_context['installed_version'] . ' is current)', ); } return null; } /* ---------------------------------------------------------------- * Loopback health probe (shared ATB_Site_Probe) * -------------------------------------------------------------- */ /** * @return array { passed: bool, failures: string[], checks: array } */ protected function health_probe() { return ATB_Site_Probe::run(); } /* ---------------------------------------------------------------- * Fail-safe (armed only between a successful upgrade and completion) * -------------------------------------------------------------- */ /** * Shutdown handler: if the operation never completed (a fatal killed * the request after the upgrade), restore the previous version and * leave an audit trail. The 'shutdown' action fires after fatals. */ public function failsafe_restore() { if ( empty( $this->atb_failsafe ) ) { return; } $atb_failsafe = $this->atb_failsafe; $this->atb_failsafe = null; $atb_restored = $atb_failsafe['upgrader']->restore_temp_backup( array( $atb_failsafe['backup'] ) ); if ( true === $atb_restored && function_exists( 'wp_clean_themes_cache' ) ) { wp_clean_themes_cache( true ); } if ( class_exists( 'Agent_Toolbelt_Log' ) ) { Agent_Toolbelt_Log::write( array( 'ability' => $this->slug(), 'user_id' => function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0, 'dry_run' => false, 'result' => ( true === $atb_restored ) ? 'rolled_back' : 'error', 'summary' => '{"theme":"' . $atb_failsafe['theme'] . '"} => fail-safe restore after the request died post-update' . ( ( true === $atb_restored ) ? '; previous version restored' : '; RESTORE FAILED' ), ) ); } } /** * Marks the operation complete so the shutdown fail-safe becomes a no-op. */ protected function disarm_failsafe() { $this->atb_failsafe = null; remove_action( 'shutdown', array( $this, 'failsafe_restore' ), 1 ); } } }