PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 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 All 33 releases
xspeed / includes / modules / Settings / SettingsModule.php

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

406 lines 14.9 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 public function ui_metadata(): array {
68 return array_merge( parent::ui_metadata(), array( 'group' => 'settings' ) );
69 }
70
71 /**
72 * `wp xspeed settings <list|get|update>` — the command whose absence
73 * dropped get_settings/update_settings from the MCP catalog (#149/#153).
74 *
75 * Reachable over MCP two ways: via the curated typed tools (which this
76 * command's existence restores to tools/list) and via the run_command
77 * gateway.
78 */
79 public function cli_commands(): array {
80 return array(
81 array(
82 'name' => 'xspeed settings',
83 'callback' => array( $this, 'cli_handler' ),
84 'shortdesc' => 'List modules, or read/update a module\'s settings.',
85 'ai_hint' => 'Read or write any xSpeed module\'s settings directly, and export/import the whole configuration. Use for bulk changes or replicating one site\'s setup onto another.',
86 'synopsis' => array(
87 // No 'options' constraint on `action`: WP_CLI validates a
88 // positional's options list against every positional it
89 // receives, so `settings get nosuchmodule` failed on the
90 // MODULE value with a generic "Invalid value specified for
91 // positional arg" and never reached the handler. Validating
92 // in cli_handler() instead keeps the error messages
93 // specific ("Unknown module X — run `settings list`").
94 array(
95 'type' => 'positional',
96 'name' => 'action',
97 'description' => 'list, get, or update. Defaults to list.',
98 'optional' => true,
99 ),
100 array(
101 'type' => 'positional',
102 'name' => 'module',
103 'description' => 'Module slug, e.g. "minify". Required for get/update.',
104 'optional' => true,
105 ),
106 array(
107 'type' => 'assoc',
108 'name' => 'values',
109 'description' => 'JSON object of setting keys to new values (update only).',
110 'optional' => true,
111 ),
112 array(
113 // `assoc`, not `flag`: the handler reads this as a
114 // VALUE ('json' === $assoc['format']). Declared as a
115 // flag the synopsis published `[--format]`, so an agent
116 // following it passed a bare --format, silently got
117 // table output, and had no error to learn from. That
118 // synopsis is what list_commands advertises over MCP,
119 // which makes an agent the caller most likely to hit
120 // it. (QA D1)
121 'type' => 'assoc',
122 'name' => 'format',
123 'description' => 'Output format: table (default) or json.',
124 'optional' => true,
125 'options' => array( 'table', 'json' ),
126 ),
127 ),
128 ),
129 );
130 }
131
132 /**
133 * CLI: `wp xspeed settings [list|get <module>|update <module> --values=<json>]`
134 *
135 * @param array<int,string> $args Positional arguments.
136 * @param array<string,string> $assoc Associative arguments.
137 */
138 public function cli_handler( array $args, array $assoc ): void {
139 $action = isset( $args[0] ) ? (string) $args[0] : 'list';
140 $module = isset( $args[1] ) ? (string) $args[1] : '';
141 $json = isset( $assoc['format'] ) && 'json' === $assoc['format'];
142
143 switch ( $action ) {
144 case 'list':
145 $this->cli_list( $json );
146 return;
147
148 case 'get':
149 $this->cli_get( $module, $json );
150 return;
151
152 case 'update':
153 $this->cli_update( $module, $assoc, $json );
154 return;
155
156 default:
157 \WP_CLI::error( sprintf( 'Unknown action "%s". Expected list, get, or update.', $action ) );
158 }
159 }
160
161
162 /**
163 * Is this module reachable from the CLI / MCP right now?
164 *
165 * Registration is not enough. Module_Registry::available() only asks
166 * whether Pro is LOADED (Tier_Registry::pro_active() checks the
167 * XSPEED_PRO_API constant), not whether it is LICENSED — so on a site
168 * with Pro installed and the licence lapsed, every Pro module was
169 * enumerable, readable and writable from here while the dashboard
170 * correctly showed it locked. (QA M2)
171 *
172 * The licence answer lives in Pro, which Free must not reference by
173 * name, so it comes through the `xspeed_module_descriptor` filter Pro's
174 * own descriptor gate uses. NOT `xspeed_pro_licensed`: Pro only ever
175 * APPLIES that one as an override and nothing listens to it, so gating
176 * on it silently passed everything — the bug this method exists to fix.
177 * With Pro absent the filter is unhooked and the default stands: a
178 * Free-only site has no Pro modules registered anyway, so nothing
179 * changes there.
180 *
181 * `license` is exempt for the same reason Pro exempts it — locking the
182 * licence module on an unlicensed site would remove the only surface
183 * that can fix the problem.
184 */
185 private static function module_reachable( string $slug ): bool {
186 $module = Module_Registry::available()[ $slug ] ?? null;
187 if ( ! $module ) {
188 return false;
189 }
190 if ( Module::TIER_PRO !== $module->tier() || 'license' === $slug ) {
191 return true;
192 }
193
194 // Ask the SAME question the dashboard asks. `xspeed_pro_licensed` is
195 // only ever APPLIED by Pro as an override hook — nothing registers it
196 // — so calling it here returned the default `true` and gated nothing.
197 // Pro DOES register `xspeed_module_descriptor`, and sets
198 // `locked => 'license'` on every Pro entry when the licence is
199 // inactive. Reusing that keeps one definition of "locked" instead of
200 // a second one in Free that can drift from the panel. (QA M2)
201 $entry = apply_filters(
202 'xspeed_module_descriptor',
203 array(
204 'slug' => $slug,
205 'tier' => $module->tier(),
206 ),
207 $module
208 );
209
210 return empty( $entry['locked'] );
211 }
212
213 /**
214 * `settings list` — every AVAILABLE module, its tier and enabled state.
215 *
216 * available(), not all(): all() is registration-scoped, so on a site with
217 * Pro installed but unlicensed it enumerated all 27 Pro modules and let
218 * them be read and written. Every other tier-gated surface —
219 * Cli_Bridge::commands(), Admin::modules_payload() — filters through
220 * available(), and this one should not be the exception. (QA M2)
221 */
222 private function cli_list( bool $json ): void {
223 $rows = array();
224 foreach ( Module_Registry::available() as $slug => $module ) {
225 if ( ! self::module_reachable( $slug ) ) {
226 continue;
227 }
228 $settings = Settings_Manager::get_public( $slug );
229 $rows[] = array(
230 'slug' => $slug,
231 'tier' => $module->tier(),
232 'version' => $module->version(),
233 'enabled' => ! empty( $settings['enabled'] ) ? 'yes' : 'no',
234 );
235 }
236 usort(
237 $rows,
238 static function ( array $a, array $b ): int {
239 return strcmp( $a['slug'], $b['slug'] );
240 }
241 );
242
243 if ( $json ) {
244 \WP_CLI::log( (string) wp_json_encode( $rows ) );
245 return;
246 }
247 foreach ( $rows as $row ) {
248 \WP_CLI::log( sprintf( '%-20s %-6s %-8s enabled=%s', $row['slug'], $row['tier'], $row['version'], $row['enabled'] ) );
249 }
250 }
251
252 /** `settings get <module>` — schema-coerced values, secrets masked. */
253 private function cli_get( string $module, bool $json ): void {
254 if ( '' === $module ) {
255 \WP_CLI::error( 'A module slug is required, e.g. `wp xspeed settings get minify`.' );
256 }
257 // available(), not get(): a Pro module on an unlicensed site must be
258 // as unreachable here as it is everywhere else, and must read as
259 // "unknown" rather than "locked" so probing cannot enumerate the Pro
260 // slug list. (QA M2)
261 if ( ! self::module_reachable( $module ) ) {
262 \WP_CLI::error( sprintf( 'Unknown module "%s". Run `wp xspeed settings list` to see the registered modules.', $module ) );
263 }
264
265 // get_public(), not get(): secrets come back masked so a credential
266 // never lands in a terminal, a CI log, or an MCP transcript. (#115)
267 $settings = Settings_Manager::get_public( $module );
268
269 if ( $json ) {
270 \WP_CLI::log( (string) wp_json_encode( $settings ) );
271 return;
272 }
273 foreach ( $settings as $key => $value ) {
274 \WP_CLI::log( sprintf( '%-28s %s', $key, self::scalar( $value ) ) );
275 }
276 }
277
278 /** `settings update <module> --values=<json>` — validated by the module schema. */
279 private function cli_update( string $module, array $assoc, bool $json ): void {
280 if ( '' === $module ) {
281 \WP_CLI::error( 'A module slug is required, e.g. `wp xspeed settings update minify --values=\'{"enabled":true}\'`.' );
282 }
283 // available(), not get(): a Pro module on an unlicensed site must be
284 // as unreachable here as it is everywhere else, and must read as
285 // "unknown" rather than "locked" so probing cannot enumerate the Pro
286 // slug list. (QA M2)
287 if ( ! self::module_reachable( $module ) ) {
288 \WP_CLI::error( sprintf( 'Unknown module "%s". Run `wp xspeed settings list` to see the registered modules.', $module ) );
289 }
290 if ( ! isset( $assoc['values'] ) || '' === $assoc['values'] ) {
291 \WP_CLI::error( 'A --values=<json> object is required, e.g. --values=\'{"enabled":true}\'.' );
292 }
293
294 $values = json_decode( (string) $assoc['values'], true );
295 if ( ! is_array( $values ) ) {
296 \WP_CLI::error( 'The --values argument must be a JSON object, e.g. --values=\'{"enabled":true}\'.' );
297 }
298
299 // Name the refusal rather than letting the write appear to succeed:
300 // Settings_Manager::update() silently strips secrets on this path
301 // (the #116 backstop), so without this the user would see a green
302 // success and an unchanged credential.
303 $secret_fields = Settings_Manager::secret_keys_in( $module, $values );
304 if ( ! empty( $secret_fields ) ) {
305 \WP_CLI::error(
306 sprintf(
307 '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.',
308 implode( ', ', $secret_fields )
309 )
310 );
311 }
312
313 // Pro licence write gate — the CLI writes through
314 // Settings_Manager::update() and so never reaches
315 // Module::update_settings(), where the gate lives. Without this a
316 // `wp xspeed settings update <pro-module>` turns a Pro feature on with
317 // no licence, exactly as the MCP handler did. (#185)
318 $module_object = \XSpeed\Module_Registry::get( $module );
319 if ( $module_object && $module_object->is_license_locked() ) {
320 \XSpeed\Activity_Log::record(
321 'license_write_refused',
322 sprintf(
323 /* translators: %s: module slug. */
324 __( 'Refused a CLI settings write to the Pro module "%s" — no valid license.', 'xspeed' ),
325 $module
326 ),
327 \XSpeed\Activity_Log::WARN
328 );
329 \WP_CLI::error(
330 sprintf(
331 '"%s" is a Pro module and this site has no active license, so the write was refused. Nothing was changed.',
332 $module
333 )
334 );
335 }
336
337 // Refuse rather than report success over a write that won't happen.
338 // update() walks the schema, so an out-of-schema key is never written
339 // and never mentioned; an in-schema key with an invalid value is
340 // dropped back to the stored value just as quietly. Both used to exit
341 // 0 with "Success", which an agent — or a human script — cannot tell
342 // apart from a real write. (#206)
343 $report = Settings_Manager::inspect_input( $module, $values );
344 if ( ! empty( $report['unknown'] ) || ! empty( $report['invalid'] ) ) {
345 $lines = array();
346 foreach ( $report['unknown'] as $key ) {
347 $line = sprintf( ' %s — not a setting of module "%s"', $key, $module );
348 $hint = Settings_Manager::hint_for_unknown_key( $key );
349 if ( '' !== $hint ) {
350 $line .= "\n " . $hint;
351 } else {
352 $near = Settings_Manager::did_you_mean( $module, $key );
353 if ( ! empty( $near ) ) {
354 $line .= "\n did you mean: " . implode( ', ', $near ) . '?';
355 }
356 }
357 $lines[] = $line;
358 }
359 foreach ( $report['invalid'] as $key ) {
360 $lines[] = sprintf( ' %s — value rejected by the schema (wrong type, or outside the allowed range/options)', $key );
361 }
362
363 $applied = empty( $report['applied'] )
364 ? 'Nothing was written.'
365 : 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'] ) );
366
367 \WP_CLI::error(
368 sprintf(
369 "Refused to update %s:\n%s\n\n%s",
370 $module,
371 implode( "\n", $lines ),
372 $applied
373 )
374 );
375 }
376
377 $updated = Settings_Manager::update( $module, $values );
378
379 if ( $json ) {
380 \WP_CLI::log( (string) wp_json_encode( $updated ) );
381 return;
382 }
383 \WP_CLI::success( sprintf( 'Updated %s.', $module ) );
384 foreach ( array_keys( $values ) as $key ) {
385 if ( array_key_exists( $key, $updated ) ) {
386 \WP_CLI::log( sprintf( '%-28s %s', $key, self::scalar( $updated[ $key ] ) ) );
387 }
388 }
389 }
390
391 /**
392 * Render a setting value for a single line of CLI output.
393 *
394 * @param mixed $value Any schema-coerced setting value.
395 */
396 private static function scalar( $value ): string {
397 if ( is_bool( $value ) ) {
398 return $value ? 'true' : 'false';
399 }
400 if ( is_array( $value ) ) {
401 return (string) wp_json_encode( $value );
402 }
403 return (string) $value;
404 }
405 }
406