PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.0
1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / modules / Settings / SettingsModule.php

SettingsModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.2.0, at includes/modules/Settings/SettingsModule.php

401 lines 14.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Settings module — a first-class CLI/MCP surface for reading and writing
4 * any module's settings.
5 *
6 * This module owns no settings of its own; it is a *surface* over
7 * Settings_Manager, the way HealthModule is a surface over Health. It exists
8 * because the curated MCP tools `get_settings` / `update_settings` are gated
9 * on an `xspeed settings` command existing (Mcp_Tools::catalog()'s
10 * $conditional map). No such command existed, so the drop loop unset both
11 * tools on every request and they never appeared in tools/list — leaving
12 * `run_command` as the only way to reach settings. (#149, and the field
13 * report of the same bug in #153.)
14 *
15 * Registering the command satisfies the existing guard rather than adding a
16 * special case to it, and gives CLI + MCP `run_command` a settings surface
17 * that was independently missing. Per IMPLEMENTATION.md §17, a module's
18 * cli_commands() entry is what makes a feature reachable from both CLI and
19 * MCP — they dispatch to these same callbacks.
20 *
21 * Credential safety: reads go through Settings_Manager::get_public(), so
22 * secrets come back masked (#115). Writes go through Settings_Manager::update(),
23 * which strips secret fields on this path as the documented backstop to the
24 * MCP `configure`-scope gate (#116) — so `wp xspeed settings update` can
25 * never set a credential, by design. Credentials are set from the dashboard.
26 *
27 * Tier: Free. The command reads the Module_Registry, so it covers Pro modules
28 * too once they register, with no Pro reference here.
29 *
30 * @package XSpeed
31 */
32
33 declare(strict_types=1);
34
35 namespace XSpeed\Modules\Settings;
36
37 defined( 'ABSPATH' ) || exit;
38
39 use XSpeed\Module;
40 use XSpeed\Module_Registry;
41 use XSpeed\Settings_Manager;
42
43 final class SettingsModule extends Module {
44
45 public const SLUG = 'settings';
46 public const TIER = self::TIER_FREE;
47 public const VERSION = '1.0.0';
48
49 /**
50 * No settings of its own and no hooks to wire — this module is purely a
51 * CLI/MCP surface. Declared explicitly so the empty body reads as
52 * intentional rather than unfinished.
53 */
54 public function boot(): void {
55 }
56
57 /**
58 * No settings_schema(): this module configures nothing. It deliberately
59 * declares no ui_panels() either — the dashboard already renders every
60 * module's settings through ModulePanel, so a "Settings" panel would be
61 * a confusing duplicate of the whole app.
62 */
63 public function settings_schema(): array {
64 return array();
65 }
66
67 /**
68 * `wp xspeed settings <list|get|update>` — the command whose absence
69 * dropped get_settings/update_settings from the MCP catalog (#149/#153).
70 *
71 * Reachable over MCP two ways: via the curated typed tools (which this
72 * command's existence restores to tools/list) and via the run_command
73 * gateway.
74 */
75 public function cli_commands(): array {
76 return array(
77 array(
78 'name' => 'xspeed settings',
79 'callback' => array( $this, 'cli_handler' ),
80 'shortdesc' => 'List modules, or read/update a module\'s settings.',
81 'synopsis' => array(
82 // No 'options' constraint on `action`: WP_CLI validates a
83 // positional's options list against every positional it
84 // receives, so `settings get nosuchmodule` failed on the
85 // MODULE value with a generic "Invalid value specified for
86 // positional arg" and never reached the handler. Validating
87 // in cli_handler() instead keeps the error messages
88 // specific ("Unknown module X — run `settings list`").
89 array(
90 'type' => 'positional',
91 'name' => 'action',
92 'description' => 'list, get, or update. Defaults to list.',
93 'optional' => true,
94 ),
95 array(
96 'type' => 'positional',
97 'name' => 'module',
98 'description' => 'Module slug, e.g. "minify". Required for get/update.',
99 'optional' => true,
100 ),
101 array(
102 'type' => 'assoc',
103 'name' => 'values',
104 'description' => 'JSON object of setting keys to new values (update only).',
105 'optional' => true,
106 ),
107 array(
108 // `assoc`, not `flag`: the handler reads this as a
109 // VALUE ('json' === $assoc['format']). Declared as a
110 // flag the synopsis published `[--format]`, so an agent
111 // following it passed a bare --format, silently got
112 // table output, and had no error to learn from. That
113 // synopsis is what list_commands advertises over MCP,
114 // which makes an agent the caller most likely to hit
115 // it. (QA D1)
116 'type' => 'assoc',
117 'name' => 'format',
118 'description' => 'Output format: table (default) or json.',
119 'optional' => true,
120 'options' => array( 'table', 'json' ),
121 ),
122 ),
123 ),
124 );
125 }
126
127 /**
128 * CLI: `wp xspeed settings [list|get <module>|update <module> --values=<json>]`
129 *
130 * @param array<int,string> $args Positional arguments.
131 * @param array<string,string> $assoc Associative arguments.
132 */
133 public function cli_handler( array $args, array $assoc ): void {
134 $action = isset( $args[0] ) ? (string) $args[0] : 'list';
135 $module = isset( $args[1] ) ? (string) $args[1] : '';
136 $json = isset( $assoc['format'] ) && 'json' === $assoc['format'];
137
138 switch ( $action ) {
139 case 'list':
140 $this->cli_list( $json );
141 return;
142
143 case 'get':
144 $this->cli_get( $module, $json );
145 return;
146
147 case 'update':
148 $this->cli_update( $module, $assoc, $json );
149 return;
150
151 default:
152 \WP_CLI::error( sprintf( 'Unknown action "%s". Expected list, get, or update.', $action ) );
153 }
154 }
155
156
157 /**
158 * Is this module reachable from the CLI / MCP right now?
159 *
160 * Registration is not enough. Module_Registry::available() only asks
161 * whether Pro is LOADED (Tier_Registry::pro_active() checks the
162 * XSPEED_PRO_API constant), not whether it is LICENSED — so on a site
163 * with Pro installed and the licence lapsed, every Pro module was
164 * enumerable, readable and writable from here while the dashboard
165 * correctly showed it locked. (QA M2)
166 *
167 * The licence answer lives in Pro, which Free must not reference by
168 * name, so it comes through the `xspeed_module_descriptor` filter Pro's
169 * own descriptor gate uses. NOT `xspeed_pro_licensed`: Pro only ever
170 * APPLIES that one as an override and nothing listens to it, so gating
171 * on it silently passed everything — the bug this method exists to fix.
172 * With Pro absent the filter is unhooked and the default stands: a
173 * Free-only site has no Pro modules registered anyway, so nothing
174 * changes there.
175 *
176 * `license` is exempt for the same reason Pro exempts it — locking the
177 * licence module on an unlicensed site would remove the only surface
178 * that can fix the problem.
179 */
180 private static function module_reachable( string $slug ): bool {
181 $module = Module_Registry::available()[ $slug ] ?? null;
182 if ( ! $module ) {
183 return false;
184 }
185 if ( Module::TIER_PRO !== $module->tier() || 'license' === $slug ) {
186 return true;
187 }
188
189 // Ask the SAME question the dashboard asks. `xspeed_pro_licensed` is
190 // only ever APPLIED by Pro as an override hook — nothing registers it
191 // — so calling it here returned the default `true` and gated nothing.
192 // Pro DOES register `xspeed_module_descriptor`, and sets
193 // `locked => 'license'` on every Pro entry when the licence is
194 // inactive. Reusing that keeps one definition of "locked" instead of
195 // a second one in Free that can drift from the panel. (QA M2)
196 $entry = apply_filters(
197 'xspeed_module_descriptor',
198 array(
199 'slug' => $slug,
200 'tier' => $module->tier(),
201 ),
202 $module
203 );
204
205 return empty( $entry['locked'] );
206 }
207
208 /**
209 * `settings list` — every AVAILABLE module, its tier and enabled state.
210 *
211 * available(), not all(): all() is registration-scoped, so on a site with
212 * Pro installed but unlicensed it enumerated all 27 Pro modules and let
213 * them be read and written. Every other tier-gated surface —
214 * Cli_Bridge::commands(), Admin::modules_payload() — filters through
215 * available(), and this one should not be the exception. (QA M2)
216 */
217 private function cli_list( bool $json ): void {
218 $rows = array();
219 foreach ( Module_Registry::available() as $slug => $module ) {
220 if ( ! self::module_reachable( $slug ) ) {
221 continue;
222 }
223 $settings = Settings_Manager::get_public( $slug );
224 $rows[] = array(
225 'slug' => $slug,
226 'tier' => $module->tier(),
227 'version' => $module->version(),
228 'enabled' => ! empty( $settings['enabled'] ) ? 'yes' : 'no',
229 );
230 }
231 usort(
232 $rows,
233 static function ( array $a, array $b ): int {
234 return strcmp( $a['slug'], $b['slug'] );
235 }
236 );
237
238 if ( $json ) {
239 \WP_CLI::log( (string) wp_json_encode( $rows ) );
240 return;
241 }
242 foreach ( $rows as $row ) {
243 \WP_CLI::log( sprintf( '%-20s %-6s %-8s enabled=%s', $row['slug'], $row['tier'], $row['version'], $row['enabled'] ) );
244 }
245 }
246
247 /** `settings get <module>` — schema-coerced values, secrets masked. */
248 private function cli_get( string $module, bool $json ): void {
249 if ( '' === $module ) {
250 \WP_CLI::error( 'A module slug is required, e.g. `wp xspeed settings get minify`.' );
251 }
252 // available(), not get(): a Pro module on an unlicensed site must be
253 // as unreachable here as it is everywhere else, and must read as
254 // "unknown" rather than "locked" so probing cannot enumerate the Pro
255 // slug list. (QA M2)
256 if ( ! self::module_reachable( $module ) ) {
257 \WP_CLI::error( sprintf( 'Unknown module "%s". Run `wp xspeed settings list` to see the registered modules.', $module ) );
258 }
259
260 // get_public(), not get(): secrets come back masked so a credential
261 // never lands in a terminal, a CI log, or an MCP transcript. (#115)
262 $settings = Settings_Manager::get_public( $module );
263
264 if ( $json ) {
265 \WP_CLI::log( (string) wp_json_encode( $settings ) );
266 return;
267 }
268 foreach ( $settings as $key => $value ) {
269 \WP_CLI::log( sprintf( '%-28s %s', $key, self::scalar( $value ) ) );
270 }
271 }
272
273 /** `settings update <module> --values=<json>` — validated by the module schema. */
274 private function cli_update( string $module, array $assoc, bool $json ): void {
275 if ( '' === $module ) {
276 \WP_CLI::error( 'A module slug is required, e.g. `wp xspeed settings update minify --values=\'{"enabled":true}\'`.' );
277 }
278 // available(), not get(): a Pro module on an unlicensed site must be
279 // as unreachable here as it is everywhere else, and must read as
280 // "unknown" rather than "locked" so probing cannot enumerate the Pro
281 // slug list. (QA M2)
282 if ( ! self::module_reachable( $module ) ) {
283 \WP_CLI::error( sprintf( 'Unknown module "%s". Run `wp xspeed settings list` to see the registered modules.', $module ) );
284 }
285 if ( ! isset( $assoc['values'] ) || '' === $assoc['values'] ) {
286 \WP_CLI::error( 'A --values=<json> object is required, e.g. --values=\'{"enabled":true}\'.' );
287 }
288
289 $values = json_decode( (string) $assoc['values'], true );
290 if ( ! is_array( $values ) ) {
291 \WP_CLI::error( 'The --values argument must be a JSON object, e.g. --values=\'{"enabled":true}\'.' );
292 }
293
294 // Name the refusal rather than letting the write appear to succeed:
295 // Settings_Manager::update() silently strips secrets on this path
296 // (the #116 backstop), so without this the user would see a green
297 // success and an unchanged credential.
298 $secret_fields = Settings_Manager::secret_keys_in( $module, $values );
299 if ( ! empty( $secret_fields ) ) {
300 \WP_CLI::error(
301 sprintf(
302 'Credential fields (%s) cannot be set from the CLI or MCP — they are stripped on this path by design. Set them in the xSpeed dashboard instead.',
303 implode( ', ', $secret_fields )
304 )
305 );
306 }
307
308 // Pro licence write gate — the CLI writes through
309 // Settings_Manager::update() and so never reaches
310 // Module::update_settings(), where the gate lives. Without this a
311 // `wp xspeed settings update <pro-module>` turns a Pro feature on with
312 // no licence, exactly as the MCP handler did. (#185)
313 $module_object = \XSpeed\Module_Registry::get( $module );
314 if ( $module_object && $module_object->is_license_locked() ) {
315 \XSpeed\Activity_Log::record(
316 'license_write_refused',
317 sprintf(
318 /* translators: %s: module slug. */
319 __( 'Refused a CLI settings write to the Pro module "%s" — no valid license.', 'xspeed' ),
320 $module
321 ),
322 \XSpeed\Activity_Log::WARN
323 );
324 \WP_CLI::error(
325 sprintf(
326 '"%s" is a Pro module and this site has no active license, so the write was refused. Nothing was changed.',
327 $module
328 )
329 );
330 }
331
332 // Refuse rather than report success over a write that won't happen.
333 // update() walks the schema, so an out-of-schema key is never written
334 // and never mentioned; an in-schema key with an invalid value is
335 // dropped back to the stored value just as quietly. Both used to exit
336 // 0 with "Success", which an agent — or a human script — cannot tell
337 // apart from a real write. (#206)
338 $report = Settings_Manager::inspect_input( $module, $values );
339 if ( ! empty( $report['unknown'] ) || ! empty( $report['invalid'] ) ) {
340 $lines = array();
341 foreach ( $report['unknown'] as $key ) {
342 $line = sprintf( ' %s — not a setting of module "%s"', $key, $module );
343 $hint = Settings_Manager::hint_for_unknown_key( $key );
344 if ( '' !== $hint ) {
345 $line .= "\n " . $hint;
346 } else {
347 $near = Settings_Manager::did_you_mean( $module, $key );
348 if ( ! empty( $near ) ) {
349 $line .= "\n did you mean: " . implode( ', ', $near ) . '?';
350 }
351 }
352 $lines[] = $line;
353 }
354 foreach ( $report['invalid'] as $key ) {
355 $lines[] = sprintf( ' %s — value rejected by the schema (wrong type, or outside the allowed range/options)', $key );
356 }
357
358 $applied = empty( $report['applied'] )
359 ? 'Nothing was written.'
360 : sprintf( 'Nothing was written — the valid keys (%s) were not applied either, so the whole payload can be corrected and re-sent.', implode( ', ', $report['applied'] ) );
361
362 \WP_CLI::error(
363 sprintf(
364 "Refused to update %s:\n%s\n\n%s",
365 $module,
366 implode( "\n", $lines ),
367 $applied
368 )
369 );
370 }
371
372 $updated = Settings_Manager::update( $module, $values );
373
374 if ( $json ) {
375 \WP_CLI::log( (string) wp_json_encode( $updated ) );
376 return;
377 }
378 \WP_CLI::success( sprintf( 'Updated %s.', $module ) );
379 foreach ( array_keys( $values ) as $key ) {
380 if ( array_key_exists( $key, $updated ) ) {
381 \WP_CLI::log( sprintf( '%-28s %s', $key, self::scalar( $updated[ $key ] ) ) );
382 }
383 }
384 }
385
386 /**
387 * Render a setting value for a single line of CLI output.
388 *
389 * @param mixed $value Any schema-coerced setting value.
390 */
391 private static function scalar( $value ): string {
392 if ( is_bool( $value ) ) {
393 return $value ? 'true' : 'false';
394 }
395 if ( is_array( $value ) ) {
396 return (string) wp_json_encode( $value );
397 }
398 return (string) $value;
399 }
400 }
401