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-install.php

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

256 lines 9.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Theme Install — server-side execution.
4 *
5 * Runs the install in-process via WP core's `Theme_Upgrader` under the
6 * App-Password user's identity (set by `REST_API::is_basic_authenticated()`
7 * before this handler dispatches). Capability checks run as that user via
8 * `current_user_can()` so unauthorised roles cannot install.
9 *
10 * Pre-App-Password versions of this ability returned a js_hook envelope and
11 * deferred to the browser's `admin-ajax.php?action=install-theme` action.
12 * App Password auth IS the WP-blessed pattern for third-party services —
13 * server-side install with a user-bound credential returns the real
14 * success/error in one round-trip and removes the "dispatched →
15 * maybe-done-next-turn" UX gap. (The REST themes controller has no POST
16 * route, but `Theme_Upgrader` is the in-process API the admin-ajax handler
17 * itself wraps, so no browser round-trip is needed.) Mirrors `PluginInstall`.
18 *
19 * @since 0.0.5
20 * @package zip-ai
21 */
22
23 namespace ZipAI\MCP\Classes\Abilities\Zipai\System;
24
25 use Theme_Upgrader;
26 use WP_Ajax_Upgrader_Skin;
27 use ZipAI\MCP\Classes\Abilities\Abstract_Ability;
28 use ZipAI\MCP\Classes\Core\Response;
29 use ZipAI\MCP\Classes\Core\Tool_Types;
30
31 if ( ! defined( 'ABSPATH' ) ) {
32 exit;
33 }
34
35 class ThemeInstall extends Abstract_Ability {
36
37 /**
38 * Flags this ability as destructive (mutates site state).
39 *
40 * @var bool
41 */
42 protected $is_destructive = true;
43
44 /**
45 * Configures the ability's id, label, description and metadata.
46 *
47 * @return void
48 */
49 public function configure() {
50 $this->id = 'zipai/install-theme';
51 $this->label = 'Install Theme';
52 $this->description = 'Install a theme from the WordPress.org repository by slug. '
53 . 'Runs in-process via `Theme_Upgrader` under the App-Password user\'s identity. '
54 . 'The user must have the `install_themes` capability (and `switch_themes` when `status: "active"`). '
55 . 'Returns synchronously with the actual result — no browser round-trip.';
56 $this->capability = 'install_themes';
57
58 $this->meta = array(
59 'tool_type' => Tool_Types::WRITE,
60 );
61 }
62
63 /**
64 * Returns the tool-type classification for this ability.
65 *
66 * @return string One of the Tool_Types constants.
67 */
68 public function get_tool_type() {
69 return Tool_Types::WRITE;
70 }
71
72 /**
73 * Returns the JSON Schema for this ability's input arguments.
74 *
75 * @return array<string,mixed> JSON Schema describing accepted arguments.
76 */
77 public function get_input_schema() {
78 return array(
79 'type' => 'object',
80 'required' => array( 'slug' ),
81 'additionalProperties' => false,
82 'properties' => array(
83 'slug' => array(
84 'type' => 'string',
85 'pattern' => '^[a-z0-9][a-z0-9-]*$',
86 'description' => 'Theme slug on WordPress.org (e.g. "twentytwentyfour", "generatepress"). Lowercase, hyphen-separated. NOT the display name.',
87 ),
88 'status' => array(
89 'type' => 'string',
90 'enum' => array( 'inactive', 'active' ),
91 'default' => 'inactive',
92 'description' => 'Optional target state after install. "active" switches the site to this theme and requires `switch_themes` capability.',
93 ),
94 ),
95 );
96 }
97
98 /**
99 * Installs a WordPress.org theme by slug, optionally activating it.
100 *
101 * @param array<string,mixed> $args Validated input arguments.
102 * @return array<string,mixed> Standardized success or error response.
103 */
104 public function execute( $args ) {
105 $raw_slug = isset( $args['slug'] ) && is_string( $args['slug'] ) ? $args['slug'] : '';
106 if ( '' === trim( $raw_slug ) ) {
107 return Response::error( 'Theme slug is required (e.g. "twentytwentyfour").' );
108 }
109 $slug = sanitize_key( $raw_slug );
110 // sanitize_key silently strips spaces/casing/punctuation — an LLM emitting
111 // "Bad Theme!" should fail loudly, not get rewritten. Reject when
112 // sanitisation changed the input or the result isn't a valid .org slug.
113 if ( $slug !== $raw_slug || ! preg_match( '/^[a-z0-9][a-z0-9-]*$/', $slug ) ) {
114 return Response::error( 'Invalid theme slug. Use the lowercase, hyphen-separated WordPress.org slug (e.g. "twentytwentyfour").' );
115 }
116
117 $want_active = isset( $args['status'] ) && 'active' === $args['status'];
118 if ( $want_active && ! current_user_can( 'switch_themes' ) ) {
119 return Response::error( 'Connected user lacks `switch_themes` capability — cannot install with status=active.' );
120 }
121
122 // Idempotent: an already-installed theme skips the download and just
123 // (optionally) switches — mirrors InstallBundledTheme. wp_get_theme()
124 // lives in wp-includes (always loaded), so the idempotent and
125 // activate-only paths need no admin includes, filesystem credentials, or
126 // the install-only gates below — switch_theme() modifies no files.
127 $existing = wp_get_theme( $slug );
128 if ( ! $existing->exists() ) {
129 // Install-only gates — they apply to the file-writing download, not
130 // to activating a theme that is already present. Honor
131 // DISALLOW_FILE_MODS, and on multisite restrict installing to
132 // network super-admins.
133 if ( ! wp_is_file_mod_allowed( 'zipai_install_theme' ) ) {
134 return Response::error( 'Theme installation is disabled on this site (DISALLOW_FILE_MODS or the file_mod_allowed filter).' );
135 }
136 if ( is_multisite() && ! is_super_admin() ) {
137 return Response::error( 'On multisite, only network super-admins may install themes.' );
138 }
139
140 // WP-Admin upgrader machinery isn't auto-loaded in REST context.
141 require_once ABSPATH . 'wp-admin/includes/file.php';
142 require_once ABSPATH . 'wp-admin/includes/misc.php';
143 require_once ABSPATH . 'wp-admin/includes/theme.php';
144 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
145 require_once ABSPATH . 'wp-admin/includes/class-wp-ajax-upgrader-skin.php';
146
147 // Initialise WP_Filesystem. On FTP-mode hosts without stored
148 // credentials this fails — return a clear, actionable error
149 // instead of a deep upgrader stack trace.
150 if ( ! WP_Filesystem() ) {
151 return Response::error(
152 'Filesystem credentials required for theme install. '
153 . 'Configure FS_METHOD or store FTP credentials in wp-config.php.'
154 );
155 }
156
157 $api = themes_api(
158 'theme_information',
159 array(
160 'slug' => $slug,
161 'fields' => array( 'sections' => false ),
162 )
163 );
164 if ( is_wp_error( $api ) ) {
165 return Response::error( sprintf( 'Could not fetch theme "%s" from WP.org: %s', $slug, $api->get_error_message() ) );
166 }
167 $download_link = is_object( $api ) && isset( $api->download_link ) && is_string( $api->download_link ) ? $api->download_link : '';
168 if ( '' === $download_link ) {
169 return Response::error( sprintf( 'Theme "%s" has no download link on WP.org.', $slug ) );
170 }
171
172 // Host-pin the WP.org-supplied download URL (same threat model as
173 // PluginInstall): the link is network-supplied data, so only honour
174 // downloads.wordpress.org archives.
175 $download_host = wp_parse_url( $download_link, PHP_URL_HOST );
176 if ( 'downloads.wordpress.org' !== $download_host ) {
177 return Response::error(
178 sprintf(
179 'Refusing to install theme "%s": download host "%s" is not downloads.wordpress.org.',
180 $slug,
181 is_string( $download_host ) ? $download_host : ''
182 )
183 );
184 }
185
186 $upgrader = new Theme_Upgrader( new WP_Ajax_Upgrader_Skin() );
187 $result = $upgrader->install( $download_link );
188
189 if ( is_wp_error( $result ) ) {
190 return Response::error( sprintf( 'Install failed: %s', $result->get_error_message() ) );
191 }
192 // install() returns null/false on certain failure paths — surface as
193 // an explicit failure rather than letting it look like success.
194 if ( ! $result ) {
195 return Response::error( 'Theme install did not complete successfully.' );
196 }
197
198 wp_clean_themes_cache();
199 $existing = wp_get_theme( $slug );
200 if ( ! $existing->exists() ) {
201 return Response::error( 'Theme installed but could not be resolved afterwards.' );
202 }
203 }
204
205 $is_active = ( wp_get_theme()->get_stylesheet() === $existing->get_stylesheet() );
206 if ( $want_active && ! $is_active ) {
207 switch_theme( $existing->get_stylesheet() );
208 // switch_theme() is void — confirm the switch actually took, so a
209 // filter that blocked it can't be reported as success.
210 $is_active = ( wp_get_theme()->get_stylesheet() === $existing->get_stylesheet() );
211 if ( ! $is_active ) {
212 return Response::error( 'Theme installed but activation did not take effect.' );
213 }
214 }
215
216 return array(
217 'success' => true,
218 'message' => sprintf(
219 'Theme "%s" installed%s.',
220 $slug,
221 $want_active && $is_active ? ' and activated' : ''
222 ),
223 'data' => array(
224 'slug' => $slug,
225 'stylesheet' => $existing->get_stylesheet(),
226 'active' => $is_active,
227 ),
228 );
229 }
230
231 /**
232 * Returns the JSON Schema for this ability's response.
233 *
234 * @return array<string,mixed> JSON Schema describing the response shape.
235 */
236 public function get_output_schema() {
237 return array(
238 'type' => 'object',
239 'required' => array( 'success' ),
240 'additionalProperties' => true,
241 'properties' => array(
242 'success' => array( 'type' => 'boolean' ),
243 'message' => array( 'type' => 'string' ),
244 'data' => array(
245 'type' => 'object',
246 'properties' => array(
247 'slug' => array( 'type' => 'string' ),
248 'stylesheet' => array( 'type' => 'string' ),
249 'active' => array( 'type' => 'boolean' ),
250 ),
251 ),
252 ),
253 );
254 }
255 }
256