PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
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 1.2.1 All 27 releases
xspeed / includes / modules / Settings / SettingsModule.php

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

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