PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.8
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.8
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / abilities / zipai / system / plugin-update.php

plugin-update.php in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.8, at inc/abilities/zipai/system/plugin-update.php

389 lines 13.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Plugin Update. The server runs this.
4 *
5 * This ability updates one or more plugins from WordPress.org. It accepts a list
6 * of slugs. It also accepts `all: true` to update every plugin that has an
7 * available update. It runs in-process as the App Password user. It uses WP
8 * core `Plugin_Upgrader::bulk_upgrade()`.
9 *
10 * The compliance checks match PluginInstall. There are five checks.
11 * 1. It checks the user capability (`update_plugins`) through the App Password.
12 * 2. It uses standard `Plugin_Upgrader` code. It uses ZIP files from WordPress.org.
13 * 3. It honors `DISALLOW_FILE_MODS` first.
14 * 4. It honors a `WP_Filesystem` init failure on FTP-mode hosts. It returns a
15 * clear error the user can act on.
16 * 5. It requires a super-admin on multisite. A sub-site admin App Password gets
17 * a clear error. It does not do a silent partial update.
18 *
19 * `bulk_upgrade()` does not throw when one slug fails. It returns `null` or a
20 * `WP_Error` in that plugin's result slot. So this ability compares the plugin
21 * version before and after the update, per slug. This catches a silent no-op.
22 * A no-op is when the upgrader reports success but the version did not change.
23 *
24 * @since 0.0.5
25 * @package zip-ai
26 */
27
28 namespace ZipAI\MCP\Classes\Abilities\Zipai\System;
29
30 use Plugin_Upgrader;
31 use WP_Ajax_Upgrader_Skin;
32 use WP_Error;
33 use ZipAI\MCP\Classes\Abilities\Abstract_Ability;
34 use ZipAI\MCP\Classes\Abilities\Zipai\System\PluginResolver;
35 use ZipAI\MCP\Classes\Core\Response;
36 use ZipAI\MCP\Classes\Core\Tool_Types;
37
38 if ( ! defined( 'ABSPATH' ) ) {
39 exit;
40 }
41
42 class PluginUpdate extends Abstract_Ability {
43
44 /**
45 * Flags this ability as destructive (mutates site state).
46 *
47 * @var bool
48 */
49 protected $is_destructive = true;
50
51 /**
52 * Configures the ability's id, label, description and metadata.
53 *
54 * @return void
55 */
56 public function configure() {
57 $this->id = 'zipai/update-plugin';
58 $this->label = 'Update Plugin';
59 $this->description = 'Update one or more plugins from WordPress.org. Pass `slugs: [..]` for specific plugins or `all: true` to update every plugin with an available update. '
60 . 'Runs in-process under the App-Password user\'s identity via `Plugin_Upgrader::bulk_upgrade()`. '
61 . 'Requires `update_plugins` capability (network-admin / super_admin on multisite). '
62 . 'Synchronous: response carries the actual per-plugin from→to version transitions. '
63 . 'Verifies each upgrade by reading back the plugin\'s Version header — catches silent failures where bulk_upgrade returns null for a slot but doesn\'t throw.';
64 $this->capability = 'update_plugins';
65
66 $this->meta = array(
67 'tool_type' => Tool_Types::WRITE,
68 );
69 }
70
71 /**
72 * Returns the tool-type classification for this ability.
73 *
74 * @return string One of the Tool_Types constants.
75 */
76 public function get_tool_type() {
77 return Tool_Types::WRITE;
78 }
79
80 /**
81 * Returns the JSON Schema for this ability's input arguments.
82 *
83 * @return array<string,mixed> JSON Schema describing accepted arguments.
84 */
85 public function get_input_schema() {
86 return array(
87 'type' => 'object',
88 'additionalProperties' => false,
89 'properties' => array(
90 'slugs' => array(
91 'type' => 'array',
92 'items' => array(
93 'type' => 'string',
94 'pattern' => '^[a-z0-9][a-z0-9-]*(?:/[a-z0-9._-]+(?:\.php)?)?$',
95 ),
96 'minItems' => 1,
97 'description' => 'Specific plugins to update. Bare slug ("contact-form-7") or WP REST plugin id "folder/file" (`.php` optional). Mutually exclusive with `all`.',
98 ),
99 'all' => array(
100 'type' => 'boolean',
101 'description' => 'When true, update every plugin with an available update. Mutually exclusive with `slugs`.',
102 ),
103 ),
104 );
105 }
106
107 /**
108 * Updates one or more plugins from WordPress.org, verifying each version bump.
109 *
110 * @param array<string,mixed> $args Validated input arguments.
111 * @return array<string,mixed> Standardized success or error response.
112 */
113 public function execute( $args ) {
114 // Hard fail on locked-down sites — same gate PluginInstall uses.
115 if ( ! wp_is_file_mod_allowed( 'zipai_update_plugin' ) ) {
116 return Response::error( 'Plugin updates are disabled on this site (DISALLOW_FILE_MODS or the file_mod_allowed filter).' );
117 }
118
119 // Multisite: plugin updates are a network-admin operation. A
120 // sub-site admin's App Password won't have `manage_network_plugins`.
121 // Fail loudly + precisely rather than silently no-op'ing every slug.
122 if ( is_multisite() && ! is_super_admin() ) {
123 return Response::error( 'On multisite, plugin updates require super_admin (network-admin). The connected user lacks that role.' );
124 }
125
126 // Discriminate input shape — exactly one of `slugs` / `all` must be set.
127 $update_all = isset( $args['all'] ) && true === $args['all'];
128 $explicit_slugs = array();
129 if ( isset( $args['slugs'] ) && is_array( $args['slugs'] ) ) {
130 foreach ( $args['slugs'] as $s ) {
131 if ( is_string( $s ) && '' !== trim( $s ) ) {
132 $explicit_slugs[] = trim( $s );
133 }
134 }
135 }
136
137 if ( $update_all && ! empty( $explicit_slugs ) ) {
138 return Response::error( 'Pass either `slugs` (specific plugins) or `all: true` — not both.' );
139 }
140 if ( ! $update_all && empty( $explicit_slugs ) ) {
141 return Response::error( 'Pass either `slugs` (array of plugin slugs) or `all: true`.' );
142 }
143
144 // Load WP-Admin upgrader stack — not auto-loaded in REST context.
145 require_once ABSPATH . 'wp-admin/includes/file.php';
146 require_once ABSPATH . 'wp-admin/includes/misc.php';
147 require_once ABSPATH . 'wp-admin/includes/plugin.php';
148 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
149 require_once ABSPATH . 'wp-admin/includes/class-wp-ajax-upgrader-skin.php';
150 require_once ABSPATH . 'wp-admin/includes/update.php';
151
152 if ( ! WP_Filesystem() ) {
153 return Response::error(
154 'Filesystem credentials required for plugin update. '
155 . 'Configure FS_METHOD or store FTP credentials in wp-config.php.'
156 );
157 }
158
159 // Force-refresh the available-updates transient so the upgrader sees
160 // the latest versions on WP.org. `wp_update_plugins()` alone early-
161 // returns when the last check was inside 12 hours, so a release
162 // newer than the last cron check would read as "already at latest".
163 // Deleting the transient first bypasses that throttle — same thing
164 // update-core.php?force-check=1 does via wp_clean_update_cache().
165 delete_site_transient( 'update_plugins' );
166 wp_update_plugins();
167 $available = get_site_transient( 'update_plugins' );
168
169 // Resolve target plugin files.
170 $plugin_files = array();
171 $skipped = array(); // { slug, plugin_file?, reason } — pre-upgrade filter
172
173 if ( $update_all ) {
174 $plugin_files = ( is_object( $available ) && ! empty( $available->response ) )
175 ? array_keys( (array) $available->response )
176 : array();
177 if ( empty( $plugin_files ) ) {
178 return Response::success(
179 array(
180 'updated' => array(),
181 'failed' => array(),
182 'skipped_no_update' => array(),
183 'message' => 'No plugin updates available — every plugin is already at its latest version.',
184 )
185 );
186 }
187 } else {
188 foreach ( $explicit_slugs as $slug ) {
189 $plugin_file = PluginResolver::resolve_plugin_file( $slug );
190 if ( null === $plugin_file ) {
191 $skipped[] = array(
192 'slug' => $slug,
193 'reason' => 'not installed',
194 );
195 continue;
196 }
197 $has_update = is_object( $available )
198 && isset( $available->response )
199 && isset( ( (array) $available->response )[ $plugin_file ] );
200 if ( ! $has_update ) {
201 $skipped[] = array(
202 'slug' => $slug,
203 'plugin_file' => $plugin_file,
204 'reason' => 'already at latest version',
205 );
206 continue;
207 }
208 $plugin_files[] = $plugin_file;
209 }
210 if ( empty( $plugin_files ) ) {
211 return Response::success(
212 array(
213 'updated' => array(),
214 'failed' => array(),
215 'skipped_no_update' => $skipped,
216 'message' => 'No updates needed for the requested plugins.',
217 )
218 );
219 }
220 }
221
222 // Snapshot pre-upgrade versions for verify.
223 $pre_versions = array();
224 foreach ( $plugin_files as $file ) {
225 $data = get_plugin_data( WP_PLUGIN_DIR . '/' . $file, false, false );
226 $pre_versions[ $file ] = (string) $data['Version'];
227 }
228
229 $upgrader = new Plugin_Upgrader( new WP_Ajax_Upgrader_Skin() );
230
231 /**
232 * Widen bulk_upgrade()'s stubbed `array|false` return: result slots
233 * carry `true|null|WP_Error|array`, and a defensive top-level WP_Error
234 * is guarded below.
235 *
236 * @var array<string,mixed>|WP_Error|false $results
237 */
238 $results = $upgrader->bulk_upgrade( $plugin_files );
239
240 // `bulk_upgrade` returns `false` only when the upgrader couldn't
241 // even start (filesystem init failed mid-flight, no plugins
242 // supplied). Top-level `WP_Error` is the same — surface and bail.
243 if ( false === $results || is_wp_error( $results ) ) {
244 return Response::error(
245 is_wp_error( $results )
246 ? 'Plugin update did not start: ' . $results->get_error_message()
247 : 'Plugin update did not start (Plugin_Upgrader::bulk_upgrade returned false).'
248 );
249 }
250
251 wp_clean_plugins_cache();
252
253 // Per-slug verify: compare post-upgrade Version against pre.
254 // `bulk_upgrade` populates `$results[$file]` with either `true`,
255 // `null` (no-op), a `WP_Error`, or the upgrader-internal array.
256 // We trust the read-back, not the result-slot type — same lesson
257 // as theme-activate (filter silently no-op'd, handler reported
258 // success). Authoritative signal: did Version advance?
259 $updated = array();
260 $failed = array();
261
262 foreach ( $plugin_files as $file ) {
263 $slot = $results[ $file ] ?? null;
264 $pre_v = $pre_versions[ $file ];
265 $post = get_plugin_data( WP_PLUGIN_DIR . '/' . $file, false, false );
266 $post_v = (string) $post['Version'];
267
268 if ( $slot instanceof WP_Error ) {
269 $failed[] = array(
270 'plugin_file' => $file,
271 'from' => $pre_v,
272 'reason' => $slot->get_error_message(),
273 );
274 continue;
275 }
276 if ( '' === $post_v ) {
277 $failed[] = array(
278 'plugin_file' => $file,
279 'from' => $pre_v,
280 'reason' => 'plugin file unreadable after upgrade — possibly half-installed',
281 );
282 continue;
283 }
284 if ( $post_v === $pre_v ) {
285 // Version didn't advance — silent failure or no-op (the
286 // upgrader's per-slot null path).
287 $failed[] = array(
288 'plugin_file' => $file,
289 'from' => $pre_v,
290 'to' => $post_v,
291 'reason' => 'version did not advance — upgrade silently rejected',
292 );
293 continue;
294 }
295 $updated[] = array(
296 'plugin_file' => $file,
297 'from' => $pre_v,
298 'to' => $post_v,
299 );
300 }
301
302 // All-fail → error envelope so the caller can't mark dependent
303 // todos done. Same truth-telling rule as PluginInstall +
304 // post-delete + core__bulk_run_wp_cli.
305 if ( empty( $updated ) && ! empty( $failed ) ) {
306 $summaries = array_map(
307 static fn ( $f ) => $f['plugin_file'] . ' (' . $f['reason'] . ')',
308 $failed
309 );
310 return Response::error(
311 sprintf(
312 'No plugins updated. Failures: %s.',
313 implode( '; ', $summaries )
314 )
315 );
316 }
317
318 return Response::success(
319 array(
320 'updated' => $updated,
321 'failed' => $failed,
322 'skipped_no_update' => $skipped,
323 'message' => sprintf(
324 '%d plugin(s) updated%s%s.',
325 count( $updated ),
326 ! empty( $failed ) ? ', ' . count( $failed ) . ' failed' : '',
327 ! empty( $skipped ) ? ', ' . count( $skipped ) . ' skipped (already current or not installed)' : ''
328 ),
329 )
330 );
331 }
332
333 /**
334 * Returns the JSON Schema for this ability's response.
335 *
336 * @return array<string,mixed> JSON Schema describing the response shape.
337 */
338 public function get_output_schema() {
339 return array(
340 'type' => 'object',
341 'required' => array( 'success' ),
342 'additionalProperties' => true,
343 'properties' => array(
344 'success' => array( 'type' => 'boolean' ),
345 'message' => array( 'type' => 'string' ),
346 'data' => array(
347 'type' => 'object',
348 'properties' => array(
349 'updated' => array(
350 'type' => 'array',
351 'items' => array(
352 'type' => 'object',
353 'properties' => array(
354 'plugin_file' => array( 'type' => 'string' ),
355 'from' => array( 'type' => 'string' ),
356 'to' => array( 'type' => 'string' ),
357 ),
358 ),
359 ),
360 'failed' => array(
361 'type' => 'array',
362 'items' => array(
363 'type' => 'object',
364 'properties' => array(
365 'plugin_file' => array( 'type' => 'string' ),
366 'from' => array( 'type' => 'string' ),
367 'to' => array( 'type' => 'string' ),
368 'reason' => array( 'type' => 'string' ),
369 ),
370 ),
371 ),
372 'skipped_no_update' => array(
373 'type' => 'array',
374 'items' => array(
375 'type' => 'object',
376 'properties' => array(
377 'slug' => array( 'type' => 'string' ),
378 'plugin_file' => array( 'type' => 'string' ),
379 'reason' => array( 'type' => 'string' ),
380 ),
381 ),
382 ),
383 ),
384 ),
385 ),
386 );
387 }
388 }
389