*/ public static function abilities(): array { return [ new Ability( 'sarcio/updates-list', __('Look at what needs updating', 'sarcio'), Ability::READ, 'Plugins, themes and WordPress itself with an update available: current and new version, whether it comes from WordPress.org (which is what lets an update be undone), and the checks a safe update runs first (stop = it will not run, warn = worth knowing).', ['properties' => [], 'required' => [], 'type' => 'object'], static fn (array $args): bool => current_user_can('update_plugins'), static fn (array $args): array => self::list(), ), new Ability( SafeUpdate::ABILITY, __('Update safely, with checks', 'sarcio'), Ability::WRITE, 'Update one plugin or theme from WordPress.org with checks, after the person ticks a box: first whether it can run here (PHP and WordPress versions, file changes allowed, disk space), then a look at the key pages (home, shop, cart, checkout, a product, contact, the menu), the update, the same look again. If something broke (a crash, a server error, the checkout or the way to pay gone) the earlier version is put back by itself; if something only looks different, the update is kept for the person to decide. Anything that went wrong lands in the Inbox. Prefer this to plugin-update and theme-update for anything from WordPress.org.', [ 'properties' => [ 'kind' => ['enum' => ['plugin', 'theme'], 'type' => 'string'], 'slug' => ['maxLength' => 200, 'minLength' => 1, 'pattern' => '^[a-z0-9-]+$', 'type' => 'string'], ], 'required' => ['kind', 'slug'], 'type' => 'object', ], static fn (array $args): bool => current_user_can(($args['kind'] ?? 'plugin') === 'theme' ? 'update_themes' : 'update_plugins'), static fn (array $args): array => SafeUpdate::run((string) $args['kind'], (string) $args['slug']), static fn (array $args): array => self::safePreview((string) $args['kind'], (string) $args['slug']), static fn (array $before, array $after): array => ($before['kind'] ?? 'plugin') === 'theme' ? self::rollbackTheme($before) : self::rollback($before), true, ), new Ability( 'sarcio/plugin-update', __('Update a plugin', 'sarcio'), Ability::WRITE, 'Update one plugin by its slug from updates-list, after the person ticks a box. For a plugin from WordPress.org, undo puts the previous version back from the directory; for any other, there is no undo.', ['properties' => ['slug' => ['maxLength' => 200, 'minLength' => 1, 'pattern' => '^[a-z0-9-]+$', 'type' => 'string']], 'required' => ['slug'], 'type' => 'object'], static fn (array $args): bool => current_user_can('update_plugins'), static fn (array $args): array => self::updatePlugin((string) $args['slug']), static fn (array $args): array => self::updatePreview((string) $args['slug']), static fn (array $before, array $after): array => self::rollback($before), true, ), new Ability( 'sarcio/theme-update', __('Update a theme', 'sarcio'), Ability::WRITE, 'Update one theme by its slug from updates-list, after the person ticks a box. For a theme from WordPress.org, undo puts the previous version back from the directory; for any other, there is no undo.', ['properties' => ['slug' => ['maxLength' => 200, 'minLength' => 1, 'pattern' => '^[a-z0-9-]+$', 'type' => 'string']], 'required' => ['slug'], 'type' => 'object'], static fn (array $args): bool => current_user_can('update_themes'), static fn (array $args): array => self::updateTheme((string) $args['slug']), static fn (array $args): array => self::themePreview((string) $args['slug']), static fn (array $before, array $after): array => self::rollbackTheme($before), true, ), ]; } /** @return array> slug => info, for themes with an update */ public static function pendingThemes(): array { self::loadAdmin(); if (function_exists('wp_update_themes')) { wp_update_themes(); } $out = []; foreach ((array) (function_exists('get_theme_updates') ? get_theme_updates() : []) as $slug => $theme) { $update = is_object($theme) && isset($theme->update) ? (array) $theme->update : []; $package = (string) ($update['package'] ?? ''); $out[(string) $slug] = [ 'current' => is_object($theme) && method_exists($theme, 'get') ? (string) $theme->get('Version') : '', 'fromDirectory' => str_starts_with($package, 'https://downloads.wordpress.org/'), 'name' => is_object($theme) && method_exists($theme, 'get') ? (string) $theme->get('Name') : (string) $slug, 'new' => (string) ($update['new_version'] ?? ''), 'requires' => (string) ($update['requires'] ?? ''), 'requiresPhp' => (string) ($update['requires_php'] ?? ''), 'slug' => (string) $slug, 'tested' => '', ]; } return $out; } /** * A backup first, where a backup plugin registered an ability for it: * best-effort, so an update never waits on a backup plugin's outage. * * @return array{plugin:string,ok:bool}|null */ public static function backupFirst(): ?array { if (!function_exists('wp_get_abilities')) { return null; } foreach ((array) wp_get_abilities() as $ability) { if (!is_object($ability) || !method_exists($ability, 'get_name') || !method_exists($ability, 'execute')) { continue; } $name = (string) $ability->get_name(); if (!preg_match('~^[a-z0-9-]*backup[a-z0-9-]*/(request|create|start|run|take)[a-z0-9-]*~', $name) && !preg_match('~^(jetpack-backup|backwpup|jetbackup|duplicator|updraftplus)/[a-z0-9-]*(backup|snapshot)~', $name)) { continue; } try { $result = $ability->execute([]); return ['ok' => !is_wp_error($result), 'plugin' => (string) strstr($name, '/', true)]; } catch (\Throwable) { return ['ok' => false, 'plugin' => (string) strstr($name, '/', true)]; } } return null; } /** The checks before a safe update, as the preview the person ticks. */ private static function safePreview(string $kind, string $slug): array { $pre = SafeUpdate::preflight($kind, $slug); if ($pre['item'] === null) { return ['error' => $pre['error'] ?? __('That has no update waiting.', 'sarcio')]; } $item = $pre['item']; $lines = array_map( static fn (array $check): string => ($check['level'] === 'stop' ? __('Stops it:', 'sarcio') : __('Worth knowing:', 'sarcio')) . ' ' . $check['text'], $pre['checks'], ); if (!$pre['ok']) { return ['error' => implode(' ', $lines)]; } return [ 'after' => $item['name'] . ' ' . $item['new'], 'before' => $item['name'] . ' ' . $item['current'], 'plugin' => ['author' => 'WordPress.org', 'name' => $item['name'], 'version' => $item['new']], 'summary' => sprintf( /* translators: 1: the plugin or theme name, 2: the old version, 3: the new version, 4: anything worth knowing first. */ __('Update %1$s from %2$s to %3$s, check the key pages before and after, and put %2$s back by itself if something breaks. %4$s', 'sarcio'), $item['name'], $item['current'], $item['new'], implode(' ', $lines), ), ]; } private static function themePreview(string $slug): array { $theme = self::pendingThemes()[$slug] ?? null; if ($theme === null) { return ['error' => __('That theme has no update waiting.', 'sarcio')]; } return [ 'after' => $theme['name'] . ' ' . $theme['new'], 'before' => $theme['name'] . ' ' . $theme['current'], 'summary' => sprintf( /* translators: 1: the theme name, 2: the old version, 3: the new version, 4: whether undo exists. */ __('Update the theme %1$s from %2$s to %3$s (%4$s)', 'sarcio'), $theme['name'], $theme['current'], $theme['new'], $theme['fromDirectory'] ? __('undo puts the old version back', 'sarcio') : __('not from WordPress.org: no undo', 'sarcio'), ), ]; } private static function updateTheme(string $slug): array { $theme = self::pendingThemes()[$slug] ?? null; if ($theme === null) { return ['error' => __('That theme has no update waiting.', 'sarcio')]; } self::loadAdmin(); $backup = self::backupFirst(); $upgrader = new \Theme_Upgrader(new \WP_Ajax_Upgrader_Skin()); $result = $upgrader->upgrade($slug); if (is_wp_error($result) || !$result) { return ['error' => __('WordPress could not update it. Try from Appearance → Themes.', 'sarcio')]; } return [ 'backup' => $backup, 'journal' => [ 'after' => ['slug' => $slug, 'version' => $theme['new']], 'before' => ['fromDirectory' => $theme['fromDirectory'], 'name' => $theme['name'], 'slug' => $slug, 'version' => $theme['current']], 'summary' => sprintf( /* translators: 1: the theme name, 2: the old version, 3: the new version. */ __('Updated the theme %1$s from %2$s to %3$s', 'sarcio'), $theme['name'], $theme['current'], $theme['new'], ), 'target' => 'theme:' . $slug, ], 'updated' => $theme['name'] . ' ' . $theme['new'], ]; } /** @param array $before */ public static function rollbackTheme(array $before): array { if (empty($before['fromDirectory']) || ($before['version'] ?? '') === '') { return ['error' => __('That theme did not come from WordPress.org, so the earlier version cannot be fetched back.', 'sarcio')]; } self::loadAdmin(); $url = sprintf('https://downloads.wordpress.org/theme/%s.%s.zip', rawurlencode((string) $before['slug']), rawurlencode((string) $before['version'])); $upgrader = new \Theme_Upgrader(new \WP_Ajax_Upgrader_Skin()); $result = $upgrader->install($url, ['overwrite_package' => true]); if (is_wp_error($result) || !$result) { return ['error' => __('WordPress.org no longer offers that version, so it could not be put back.', 'sarcio')]; } return ['restored' => true, 'version' => (string) $before['version']]; } public static function loadAdmin(): void { require_once ABSPATH . 'wp-admin/includes/plugin.php'; require_once ABSPATH . 'wp-admin/includes/update.php'; require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/misc.php'; require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; } /** @return array> plugin file => info, for plugins with an update */ public static function pendingPlugins(): array { self::loadAdmin(); if (function_exists('wp_update_plugins')) { wp_update_plugins(); } $updates = function_exists('get_plugin_updates') ? get_plugin_updates() : []; $out = []; foreach ((array) $updates as $file => $plugin) { $update = $plugin->update ?? null; if (!is_object($update)) { continue; } $package = (string) ($update->package ?? ''); $out[(string) $file] = [ 'current' => (string) ($plugin->Version ?? ''), 'file' => (string) $file, 'fromDirectory' => str_starts_with($package, 'https://downloads.wordpress.org/'), 'name' => (string) ($plugin->Name ?? $file), 'new' => (string) ($update->new_version ?? ''), 'requires' => (string) ($update->requires ?? ''), 'requiresPhp' => (string) ($update->requires_php ?? ''), 'slug' => (string) ($update->slug ?? dirname((string) $file)), 'tested' => (string) ($update->tested ?? ''), ]; } return $out; } private static function list(): array { self::loadAdmin(); $themes = array_values(self::pendingThemes()); $core = []; foreach ((array) (function_exists('get_core_updates') ? get_core_updates() : []) as $update) { if (is_object($update) && ($update->response ?? '') === 'upgrade') { $core[] = ['new' => (string) ($update->version ?? ''), 'current' => function_exists('get_bloginfo') ? (string) get_bloginfo('version') : '']; } } $withChecks = static fn (array $item): array => $item + ['checks' => SafeUpdate::checks($item + ['file' => $item['file'] ?? ''])]; return [ 'core' => $core, 'note' => __('The assistant updates plugins and themes one at a time, with checks; WordPress itself updates from Dashboard → Updates.', 'sarcio'), 'plugins' => array_map($withChecks, array_values(self::pendingPlugins())), 'themes' => array_map($withChecks, $themes), ]; } public static function find(string $slug): ?array { foreach (self::pendingPlugins() as $plugin) { if ($plugin['slug'] === $slug || dirname($plugin['file']) === $slug) { return $plugin; } } return null; } private static function updatePreview(string $slug): array { $plugin = self::find($slug); if ($plugin === null) { return ['error' => __('That plugin has no update waiting.', 'sarcio')]; } return [ 'after' => $plugin['name'] . ' ' . $plugin['new'], 'before' => $plugin['name'] . ' ' . $plugin['current'], 'plugin' => ['author' => $plugin['fromDirectory'] ? 'WordPress.org' : __('another source', 'sarcio'), 'name' => $plugin['name'], 'version' => $plugin['new']], 'summary' => $plugin['fromDirectory'] ? sprintf( /* translators: 1: the plugin name, 2: the old version, 3: the new version. */ __('Update %1$s from %2$s to %3$s (undo puts %2$s back)', 'sarcio'), $plugin['name'], $plugin['current'], $plugin['new'], ) : sprintf( /* translators: 1: the plugin name, 2: the old version, 3: the new version. */ __('Update %1$s from %2$s to %3$s (not from WordPress.org: no undo)', 'sarcio'), $plugin['name'], $plugin['current'], $plugin['new'], ), ]; } private static function updatePlugin(string $slug): array { $plugin = self::find($slug); if ($plugin === null) { return ['error' => __('That plugin has no update waiting.', 'sarcio')]; } self::loadAdmin(); $backup = self::backupFirst(); $result = self::upgradePlugin($plugin['file']); if (is_wp_error($result) || !$result) { return ['error' => __('WordPress could not update it. Try from Plugins → Installed Plugins.', 'sarcio')]; } return [ 'backup' => $backup, 'journal' => [ 'after' => ['file' => $plugin['file'], 'slug' => $plugin['slug'], 'version' => $plugin['new']], 'before' => ['file' => $plugin['file'], 'fromDirectory' => $plugin['fromDirectory'], 'name' => $plugin['name'], 'slug' => $plugin['slug'], 'version' => $plugin['current']], 'summary' => sprintf( /* translators: 1: the plugin name, 2: the old version, 3: the new version. */ __('Updated %1$s from %2$s to %3$s', 'sarcio'), $plugin['name'], $plugin['current'], $plugin['new'], ), 'target' => 'plugin:' . $plugin['slug'], ], 'updated' => $plugin['name'] . ' ' . $plugin['new'], ]; } /** * Update one plugin the way the Plugins screen's own Update now link does * (a one-item bulk update), which keeps an active plugin active; the * single-item upgrade switches it off and leaves turning it back on to * the next page load. * * @return mixed true or the install result on success, false or a WP_Error otherwise */ public static function upgradePlugin(string $file): mixed { self::loadAdmin(); $upgrader = new \Plugin_Upgrader(new \WP_Ajax_Upgrader_Skin()); $results = $upgrader->bulk_upgrade([$file]); if (function_exists('wp_clean_plugins_cache')) { wp_clean_plugins_cache(true); } return is_array($results) ? ($results[$file] ?? false) : $results; } /** @param array $before */ public static function rollback(array $before): array { if (empty($before['fromDirectory']) || ($before['version'] ?? '') === '') { return ['error' => __('That plugin did not come from WordPress.org, so the earlier version cannot be fetched back. Reinstall it from its own source.', 'sarcio')]; } self::loadAdmin(); $slug = (string) $before['slug']; $url = sprintf('https://downloads.wordpress.org/plugin/%s.%s.zip', rawurlencode($slug), rawurlencode((string) $before['version'])); $wasActive = function_exists('is_plugin_active') && is_plugin_active((string) $before['file']); $upgrader = new \Plugin_Upgrader(new \WP_Ajax_Upgrader_Skin()); $result = $upgrader->install($url, ['overwrite_package' => true]); if (is_wp_error($result) || !$result) { return ['error' => __('WordPress.org no longer offers that version, so it could not be put back.', 'sarcio')]; } if ($wasActive && function_exists('activate_plugin')) { activate_plugin((string) $before['file']); } if (function_exists('wp_clean_plugins_cache')) { wp_clean_plugins_cache(true); } return ['restored' => true, 'version' => (string) $before['version']]; } }