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 / theme-update.php

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

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