| 1 |
<?php |
| 2 |
/** |
| 3 |
* MCP tool registry — the single source of truth for the tools xSpeed |
| 4 |
* exposes to AI assistants. |
| 5 |
* |
| 6 |
* Each tool declares an MCP-style descriptor (name, description, JSON |
| 7 |
* Schema inputSchema) and a handler that runs against the Free engine. |
| 8 |
* Consumed by BOTH: |
| 9 |
* - Mcp_Server (the per-site JSON-RPC endpoint at /xspeed/mcp), and |
| 10 |
* - McpModule's REST tool routes (the optional hosted-broker path), |
| 11 |
* so the two transports can never drift. |
| 12 |
* |
| 13 |
* Handlers take an associative array of already-decoded arguments and |
| 14 |
* return either a plain array (serialized to JSON in the MCP result) or |
| 15 |
* a WP_Error (surfaced as an MCP tool error). |
| 16 |
* |
| 17 |
* The plugin adds ZERO cache logic here — every handler is a thin proxy |
| 18 |
* to Cache / Settings / Settings_Manager / Server / Admin / Pro_Audit / |
| 19 |
* Cache_Benchmark. |
| 20 |
* |
| 21 |
* @package XSpeed |
| 22 |
*/ |
| 23 |
|
| 24 |
declare(strict_types=1); |
| 25 |
|
| 26 |
namespace XSpeed\Modules\Mcp; |
| 27 |
|
| 28 |
use XSpeed\Cache; |
| 29 |
use XSpeed\Server; |
| 30 |
use XSpeed\Admin; |
| 31 |
use XSpeed\Settings; |
| 32 |
use XSpeed\Settings_Manager; |
| 33 |
use XSpeed\Module_Registry; |
| 34 |
use XSpeed\Pro_Audit; |
| 35 |
use XSpeed\Cache_Benchmark; |
| 36 |
use XSpeed\Tier_Registry; |
| 37 |
use XSpeed\Database_Cleaner; |
| 38 |
|
| 39 |
defined( 'ABSPATH' ) || exit; |
| 40 |
|
| 41 |
final class Mcp_Tools { |
| 42 |
|
| 43 |
/** Valid cache purge types. */ |
| 44 |
public const PURGE_TYPES = array( 'all', 'page', 'assets', 'object', 'rest', 'cloudflare', 'cdn' ); |
| 45 |
|
| 46 |
/** |
| 47 |
* Per-call read-only override. Null means "defer to the pairing token's |
| 48 |
* scope" (the JSON-RPC path that predates OAuth). true/false is set by |
| 49 |
* Mcp_Server when an OAuth access token (with its own scope) authorized |
| 50 |
* the request, so a read-only OAuth grant is enforced even though the |
| 51 |
* pairing token may be read-write (or absent). |
| 52 |
* |
| 53 |
* @var bool|null |
| 54 |
*/ |
| 55 |
private static $read_only_override = null; |
| 56 |
|
| 57 |
/** |
| 58 |
* Set the active credential's read-only state for the current request. |
| 59 |
* Passing null clears the override (back to the pairing-token default). |
| 60 |
* |
| 61 |
* @param bool|null $read_only Whether the active credential is read-only. |
| 62 |
*/ |
| 63 |
public static function set_read_only_override( ?bool $read_only ): void { |
| 64 |
self::$read_only_override = $read_only; |
| 65 |
} |
| 66 |
|
| 67 |
/** |
| 68 |
* Whether the active MCP credential is limited to read-only tools. Uses |
| 69 |
* the per-call override when set, else the pairing token's scope. |
| 70 |
*/ |
| 71 |
private static function is_read_only(): bool { |
| 72 |
if ( null !== self::$read_only_override ) { |
| 73 |
return self::$read_only_override; |
| 74 |
} |
| 75 |
return Mcp_Pairing::is_read_only(); |
| 76 |
} |
| 77 |
|
| 78 |
/** |
| 79 |
* Per-call `configure` grant. Writing credential/secret fields over MCP is |
| 80 |
* gated on this and it is OFF by default — even a write-scoped connection |
| 81 |
* cannot rewrite an API token or password unless it was granted the |
| 82 |
* explicit `configure` scope. Null means "no per-call grant" (the pairing |
| 83 |
* token / JSON-RPC path), where it falls back to a filter. (#116) |
| 84 |
* |
| 85 |
* @var bool|null |
| 86 |
*/ |
| 87 |
private static $configure_override = null; |
| 88 |
|
| 89 |
/** |
| 90 |
* Set whether the active credential may write secret fields (the OAuth |
| 91 |
* `configure` scope). Passing null clears it back to the filter default. |
| 92 |
* |
| 93 |
* @param bool|null $can_configure Whether the credential carries `configure`. |
| 94 |
*/ |
| 95 |
public static function set_configure_override( ?bool $can_configure ): void { |
| 96 |
self::$configure_override = $can_configure; |
| 97 |
} |
| 98 |
|
| 99 |
/** |
| 100 |
* Whether the active MCP credential may write credential/secret fields. |
| 101 |
* Uses the per-call override (OAuth `configure` scope) when set; otherwise |
| 102 |
* the `xspeed_mcp_allow_credential_writes` filter, which defaults to false |
| 103 |
* so credential writes are off by default on every connection — including |
| 104 |
* the pairing token. A site owner who wants an agent to manage credentials |
| 105 |
* opts in by returning true from that filter. (#116) |
| 106 |
*/ |
| 107 |
public static function can_configure(): bool { |
| 108 |
if ( null !== self::$configure_override ) { |
| 109 |
return self::$configure_override; |
| 110 |
} |
| 111 |
/** |
| 112 |
* Allow MCP connections to write credential (secret) fields. Off by |
| 113 |
* default; see docs/MCP-SERVER.md. Applies to pairing-token connections |
| 114 |
* and any OAuth grant lacking the `configure` scope. |
| 115 |
* |
| 116 |
* @param bool $allow Whether credential writes over MCP are permitted. |
| 117 |
*/ |
| 118 |
return (bool) apply_filters( 'xspeed_mcp_allow_credential_writes', false ); |
| 119 |
} |
| 120 |
|
| 121 |
/** |
| 122 |
* Full tool catalog: name => descriptor. `handler` is a callable |
| 123 |
* ( array $args ) : array|\WP_Error. `write` marks tools that mutate |
| 124 |
* state (used for read-only scope enforcement). |
| 125 |
* |
| 126 |
* @return array<string, array{description:string, inputSchema:array, handler:callable, write:bool}> |
| 127 |
*/ |
| 128 |
public static function catalog(): array { |
| 129 |
$catalog = array( |
| 130 |
'get_cache_status' => array( |
| 131 |
'description' => 'Get cache status for this WordPress site: whether caching is enabled, cache stats (cached pages, size, hit ratio, last purge), and the detected web server.', |
| 132 |
'inputSchema' => self::object_schema( array(), array() ), |
| 133 |
'write' => false, |
| 134 |
'handler' => array( self::class, 'get_cache_status' ), |
| 135 |
), |
| 136 |
'list_modules' => array( |
| 137 |
'description' => 'List all xSpeed modules (free and Pro) with their settings schema and status.', |
| 138 |
'inputSchema' => self::object_schema( array(), array() ), |
| 139 |
'write' => false, |
| 140 |
'handler' => array( self::class, 'list_modules' ), |
| 141 |
), |
| 142 |
'get_site_info' => array( |
| 143 |
'description' => 'Get facts about this site and install: whether xSpeed Pro is active and licensed, plugin/WordPress/PHP versions, and the detected web server. Use this rather than inferring the tier from the module list.', |
| 144 |
'inputSchema' => self::object_schema( array(), array() ), |
| 145 |
'write' => false, |
| 146 |
'handler' => array( self::class, 'get_site_info' ), |
| 147 |
), |
| 148 |
'optimize_site' => array( |
| 149 |
'description' => 'Make this site faster, end to end: measure, apply the recommended settings ONE AT A TIME, check the page still renders after each, and undo any change that breaks it. Returns what was applied, the site\'s performance score, `next_steps` (riskier settings that could help but are NOT applied automatically), and `unfixable` (problems no caching plugin can reach). ALWAYS relay all three to the user: report the score and what is still wrong, then — if `next_steps` is non-empty — describe each one WITH its stated `risk` and ASK whether to run again with aggressiveness "aggressive". Never enable aggressive settings without the user agreeing first, and never present `unfixable` items as things you can solve; they need the site owner or the host. A site where nothing was left to do is a real, good answer — say so plainly rather than apologising or retrying. Use `dry_run` to preview the plan. The `score` object carries `age_seconds` and `stale`: quote the score WITH how recently it was measured, and never describe a stale score as the result this run produced — when a measurement could not be taken, say the number is old rather than implying it is current. WHEN CHANGES WERE APPLIED the response carries `verify_urls` and `verify_note`: the safety checks read HTML in PHP and cannot execute JavaScript, so a page can pass every one of them and still be broken in a browser. Before reporting success, OPEN each URL in `verify_urls` if you have any way to load a page and confirm it renders with no console errors; if you cannot, tell the user those URLs need checking and what a problem would look like. `verified: true` means the HTML checks passed — it is not a statement that the site works.', |
| 150 |
'inputSchema' => self::object_schema( |
| 151 |
array( |
| 152 |
'aggressiveness' => array( |
| 153 |
'type' => 'string', |
| 154 |
'enum' => array( 'safe', 'standard', 'aggressive' ), |
| 155 |
'description' => 'How far to go. Defaults to standard.', |
| 156 |
), |
| 157 |
'dry_run' => array( |
| 158 |
'type' => 'boolean', |
| 159 |
'description' => 'Return the plan without changing anything.', |
| 160 |
), |
| 161 |
'measure_score' => array( |
| 162 |
'type' => 'string', |
| 163 |
'enum' => array( 'auto', 'never', 'always' ), |
| 164 |
'description' => 'Whether to take a fresh PageSpeed measurement. auto (default) measures when the stored score is stale and after changes land; never reuses the stored score; always measures even for a dry run. Measurements are rate-limited, so a run inside the cooldown returns the stored score with its age rather than a new one.', |
| 165 |
), |
| 166 |
'target_score' => array( |
| 167 |
'type' => 'integer', |
| 168 |
'minimum' => 1, |
| 169 |
'maximum' => 100, |
| 170 |
'description' => 'Repeat the optimize cycle toward this score instead of running a single pass. Each round costs a real PageSpeed measurement and up to two minutes, so pass it only when the user asked for a specific number. Requires the iterative tuner; without it the run is a single pass and the report says so in `stopped_because`. The run also stops early when further rounds stop helping — either way, read `stopped_because` and relay it rather than retrying.', |
| 171 |
), |
| 172 |
'max_rounds' => array( |
| 173 |
'type' => 'integer', |
| 174 |
'minimum' => 1, |
| 175 |
'description' => 'Ceiling on rounds when target_score is set. Clamped to what the tuner allows.', |
| 176 |
), |
| 177 |
), |
| 178 |
array() |
| 179 |
), |
| 180 |
'write' => true, |
| 181 |
'handler' => array( self::class, 'optimize_site' ), |
| 182 |
), |
| 183 |
'run_benchmark' => array( |
| 184 |
'description' => 'Run a before/after cache benchmark on the home page and return the timings. Each side reports bytes (decoded payload) and bytes_transferred (compressed wire size).', |
| 185 |
'inputSchema' => self::object_schema( array(), array() ), |
| 186 |
'write' => false, |
| 187 |
'handler' => array( self::class, 'run_benchmark' ), |
| 188 |
), |
| 189 |
'get_pro_audit' => array( |
| 190 |
'description' => 'Personalized list of Pro features that would benefit THIS site, from its current settings and cache stats.', |
| 191 |
'inputSchema' => self::object_schema( array(), array() ), |
| 192 |
'write' => false, |
| 193 |
'handler' => array( self::class, 'get_pro_audit' ), |
| 194 |
), |
| 195 |
'purge_cache' => array( |
| 196 |
'description' => 'Purge the site cache and report what was actually cleared, what was skipped and why. "type" selects what to purge: all, page, assets, object, rest, cloudflare, or cdn. Defaults to all.', |
| 197 |
'inputSchema' => self::object_schema( |
| 198 |
array( |
| 199 |
'type' => array( |
| 200 |
'type' => 'string', |
| 201 |
'enum' => self::PURGE_TYPES, |
| 202 |
'description' => 'What to purge. Defaults to "all".', |
| 203 |
), |
| 204 |
), |
| 205 |
array() |
| 206 |
), |
| 207 |
'write' => true, |
| 208 |
'handler' => array( self::class, 'purge_cache' ), |
| 209 |
), |
| 210 |
'toggle_cache' => array( |
| 211 |
'description' => 'Enable or disable page caching. Installs/removes the cache drop-in and WP_CACHE constant as needed.', |
| 212 |
'inputSchema' => self::object_schema( |
| 213 |
array( |
| 214 |
'enabled' => array( |
| 215 |
'type' => 'boolean', |
| 216 |
'description' => 'true to enable caching, false to disable.', |
| 217 |
), |
| 218 |
), |
| 219 |
array( 'enabled' ) |
| 220 |
), |
| 221 |
'write' => true, |
| 222 |
'handler' => array( self::class, 'toggle_cache' ), |
| 223 |
), |
| 224 |
'get_settings' => array( |
| 225 |
'description' => 'Read the settings for a given xSpeed module (e.g. "minify", "gzip"). Returns schema-validated values.', |
| 226 |
'inputSchema' => self::object_schema( |
| 227 |
array( |
| 228 |
'module' => array( |
| 229 |
'type' => 'string', |
| 230 |
'description' => 'The module slug, e.g. "minify".', |
| 231 |
), |
| 232 |
), |
| 233 |
array( 'module' ) |
| 234 |
), |
| 235 |
'write' => false, |
| 236 |
'handler' => array( self::class, 'get_settings' ), |
| 237 |
), |
| 238 |
'update_settings' => array( |
| 239 |
'description' => 'Update settings for a given xSpeed module. "values" is an object of setting keys to new values; unknown keys are stripped and invalid values rejected by the module schema.', |
| 240 |
'inputSchema' => self::object_schema( |
| 241 |
array( |
| 242 |
'module' => array( |
| 243 |
'type' => 'string', |
| 244 |
'description' => 'The module slug, e.g. "minify".', |
| 245 |
), |
| 246 |
'values' => array( |
| 247 |
'type' => 'object', |
| 248 |
'description' => 'Map of setting keys to new values.', |
| 249 |
), |
| 250 |
), |
| 251 |
array( 'module', 'values' ) |
| 252 |
), |
| 253 |
'write' => true, |
| 254 |
'handler' => array( self::class, 'update_settings' ), |
| 255 |
), |
| 256 |
// --- Promoted high-value actions: dedicated typed tools so the AI |
| 257 |
// calls them directly (no run_command hop). Each is a thin wrapper |
| 258 |
// over Cli_Bridge, so Free tools can drive Pro actions (psi, ccss) |
| 259 |
// without a cross-repo class reference, and none can drift from the |
| 260 |
// CLI. --- |
| 261 |
'purge_cloudflare' => array( |
| 262 |
'description' => 'Purge the Cloudflare edge cache for this site (requires Cloudflare connected in the Cloudflare module).', |
| 263 |
'inputSchema' => self::object_schema( array(), array() ), |
| 264 |
'write' => true, |
| 265 |
'handler' => array( self::class, 'purge_cloudflare' ), |
| 266 |
), |
| 267 |
'scan_database' => array( |
| 268 |
'description' => 'Preview database bloat — post revisions, auto-drafts, trashed posts, spam comments, expired transients, orphaned meta — with a count per category. Deletes NOTHING. Also returns the confirm_token that clean_database requires, so this is always the first step before any deletion.', |
| 269 |
'inputSchema' => self::object_schema( array(), array() ), |
| 270 |
'write' => false, |
| 271 |
'handler' => array( self::class, 'scan_database' ), |
| 272 |
), |
| 273 |
'clean_database' => array( |
| 274 |
'description' => 'PERMANENTLY DELETE database bloat — post revisions, trashed posts, spam comments and the other categories enabled in the Database module settings. This is not a cache purge: it destroys real content and CANNOT be undone. Requires a confirm_token from scan_database, which shows the caller exactly what would be removed; the call is refused without one.', |
| 275 |
'inputSchema' => self::object_schema( |
| 276 |
array( |
| 277 |
'confirm_token' => array( |
| 278 |
'type' => 'string', |
| 279 |
'description' => 'The token returned by scan_database. Required — it proves the caller has seen what will be deleted. Expires after 5 minutes and is invalidated if the database changes.', |
| 280 |
), |
| 281 |
), |
| 282 |
array( 'confirm_token' ) |
| 283 |
), |
| 284 |
'write' => true, |
| 285 |
'handler' => array( self::class, 'clean_database' ), |
| 286 |
), |
| 287 |
'flush_object_cache' => array( |
| 288 |
'description' => 'Flush the persistent object cache (Redis / Memcached), if enabled.', |
| 289 |
'inputSchema' => self::object_schema( array(), array() ), |
| 290 |
'write' => true, |
| 291 |
'handler' => array( self::class, 'flush_object_cache' ), |
| 292 |
), |
| 293 |
'start_preloader' => array( |
| 294 |
'description' => 'Start the cache preloader — crawls the sitemap to warm the page cache in the background.', |
| 295 |
'inputSchema' => self::object_schema( array(), array() ), |
| 296 |
'write' => true, |
| 297 |
'handler' => array( self::class, 'start_preloader' ), |
| 298 |
), |
| 299 |
'run_score' => array( |
| 300 |
'description' => 'Run an external performance audit (PageSpeed Insights, or GTmetrix when configured) against this site. Running it counts as the opt-in for external scores. With the site\'s own API key the score plus Core Web Vitals come back in the response; a keyless PageSpeed audit on a Hub-connected site is queued instead — the response says so, and the result lands in the history a minute or two later (poll with run_command "score status", or read get_score_history). Spends the site\'s own configured API quota when a key is set.', |
| 301 |
'inputSchema' => self::object_schema( |
| 302 |
array( |
| 303 |
'target' => array( |
| 304 |
'type' => 'string', |
| 305 |
'description' => 'URL to audit. Defaults to the configured URL, then the home page.', |
| 306 |
), |
| 307 |
'strategy' => array( |
| 308 |
'type' => 'string', |
| 309 |
'enum' => array( 'mobile', 'desktop' ), |
| 310 |
'description' => 'mobile (default) or desktop. PageSpeed Insights only.', |
| 311 |
), |
| 312 |
'provider' => array( |
| 313 |
'type' => 'string', |
| 314 |
'description' => 'Audit provider, when the site has more than one configured.', |
| 315 |
), |
| 316 |
'force' => array( |
| 317 |
'type' => 'boolean', |
| 318 |
'description' => 'Re-run even when a recent cached result exists. Use after a change you want measured immediately.', |
| 319 |
), |
| 320 |
), |
| 321 |
array() |
| 322 |
), |
| 323 |
// Classified `write`, deliberately. #147 asked whether a |
| 324 |
// read-only grant should be able to call this, since a run |
| 325 |
// changes no site CONFIGURATION. But it spends the site's own |
| 326 |
// metered PSI/GTmetrix quota and persists a Score_Store row, |
| 327 |
// and "read-only" should mean a call cannot cost the owner |
| 328 |
// anything. The gap this closes is that Free had NO typed |
| 329 |
// trigger at all: run_pagespeed is conditional on the Pro-only |
| 330 |
// `xspeed psi` command and silently drops off tools/list here, |
| 331 |
// leaving only run_command — also write, and a gateway to the |
| 332 |
// entire CLI surface. A write-scoped Hub connection now gets a |
| 333 |
// first-class trigger instead of the blunt instrument. |
| 334 |
'write' => true, |
| 335 |
'handler' => array( self::class, 'run_score' ), |
| 336 |
), |
| 337 |
'run_pagespeed' => array( |
| 338 |
'description' => 'Run an external performance audit (PageSpeed Insights, or GTmetrix when configured) and return the score + Core Web Vitals. Defaults to the site home page, mobile strategy. Running it counts as the opt-in for external scores; a keyless PageSpeed audit routes through xSpeed Hub when the site is connected.', |
| 339 |
'inputSchema' => self::object_schema( |
| 340 |
array( |
| 341 |
'url' => array( |
| 342 |
'type' => 'string', |
| 343 |
'description' => 'URL to audit. Defaults to the site home page.', |
| 344 |
), |
| 345 |
'strategy' => array( |
| 346 |
'type' => 'string', |
| 347 |
'enum' => array( 'mobile', 'desktop' ), |
| 348 |
'description' => 'Audit strategy. Defaults to "mobile".', |
| 349 |
), |
| 350 |
'force' => array( |
| 351 |
'type' => 'boolean', |
| 352 |
'description' => 'Re-run even when a recent cached result exists. Use after a change you want measured immediately.', |
| 353 |
), |
| 354 |
'provider' => array( |
| 355 |
'type' => 'string', |
| 356 |
'description' => 'Audit provider, when the site has more than one configured.', |
| 357 |
), |
| 358 |
), |
| 359 |
array() |
| 360 |
), |
| 361 |
// `write`, matching run_score — the two dispatch to the same |
| 362 |
// `xspeed psi` and cost the owner the same metered quota, so |
| 363 |
// classifying them oppositely let a read-only grant make a real |
| 364 |
// outbound audit through this one while the other refused it. |
| 365 |
// Aligned toward write rather than read: generate_critical_css |
| 366 |
// sets the precedent that spending an external quota is a write |
| 367 |
// even when no site configuration changes. (QA B2 on #162) |
| 368 |
'write' => true, |
| 369 |
'handler' => array( self::class, 'run_pagespeed' ), |
| 370 |
), |
| 371 |
'generate_critical_css' => array( |
| 372 |
'description' => 'Generate above-the-fold Critical CSS for the site (Pro). Calls the external generator and stores the result.', |
| 373 |
'inputSchema' => self::object_schema( array(), array() ), |
| 374 |
'write' => true, |
| 375 |
'handler' => array( self::class, 'generate_critical_css' ), |
| 376 |
), |
| 377 |
'get_health' => array( |
| 378 |
'description' => 'Full health diagnostics: every Health check (drop-in, WP_CACHE, server rewrite, expiry-vs-preload, Set-Cookie poisoning, conflicts), cache stats, hourly hit/miss buckets, the daily hit-ratio series, and recent activity. The single best first call when diagnosing a low hit ratio.', |
| 379 |
'inputSchema' => self::object_schema( array(), array() ), |
| 380 |
'write' => false, |
| 381 |
'handler' => array( self::class, 'get_health' ), |
| 382 |
), |
| 383 |
'get_benchmark_history' => array( |
| 384 |
'description' => 'Stored benchmark runs (oldest to newest: timestamps, uncached/cached ms, savings, transfer bytes) plus recent settings-change events for correlating a change with its performance effect.', |
| 385 |
'inputSchema' => self::object_schema( |
| 386 |
array( |
| 387 |
'limit' => array( |
| 388 |
'type' => 'integer', |
| 389 |
'description' => 'Max runs to return (default 100).', |
| 390 |
), |
| 391 |
), |
| 392 |
array() |
| 393 |
), |
| 394 |
'write' => false, |
| 395 |
'handler' => array( self::class, 'get_benchmark_history' ), |
| 396 |
), |
| 397 |
'get_score_history' => array( |
| 398 |
'description' => 'Stored EXTERNAL audit runs (PageSpeed Insights / GTmetrix): score, Core Web Vitals (LCP/FCP/CLS/TBT/SI/TTFB), which tool ran it, and the report link where one exists. Failed runs are included: ok is false and error says why, with score null. Never average or trend a run whose ok is false — it measured nothing. Read-only — returns what this site already measured and never starts a new audit. Use run_score to actually run one.', |
| 399 |
'inputSchema' => self::object_schema( |
| 400 |
array( |
| 401 |
'limit' => array( |
| 402 |
'type' => 'integer', |
| 403 |
'description' => 'Max runs to return, newest first (default 100).', |
| 404 |
), |
| 405 |
), |
| 406 |
array() |
| 407 |
), |
| 408 |
'write' => false, |
| 409 |
'handler' => array( self::class, 'get_score_history' ), |
| 410 |
), |
| 411 |
// --- Actions promoted out of the generated `xspeed_*` aliases. |
| 412 |
// Each was previously reachable ONLY as an `action` string on a |
| 413 |
// coarse generated tool that was marked write regardless, so a |
| 414 |
// read-only connection lost the read ones. Typed here with an |
| 415 |
// honest kind so the AI stops guessing and the deny-list has one |
| 416 |
// name per action. --- |
| 417 |
'get_cache_inventory' => array( |
| 418 |
'description' => 'Inspect what is actually in the page cache: which pages are cached and how old they are, or where the disk usage goes. Read-only.', |
| 419 |
'inputSchema' => self::object_schema( |
| 420 |
array( |
| 421 |
'detail' => array( |
| 422 |
'type' => 'string', |
| 423 |
'enum' => array( 'pages', 'size' ), |
| 424 |
'description' => '"pages" lists cached pages and their age; "size" breaks down disk usage. Defaults to "pages".', |
| 425 |
), |
| 426 |
'limit' => array( |
| 427 |
'type' => 'string', |
| 428 |
'description' => 'Max rows to return (pages only).', |
| 429 |
), |
| 430 |
), |
| 431 |
array() |
| 432 |
), |
| 433 |
'write' => false, |
| 434 |
'handler' => array( self::class, 'get_cache_inventory' ), |
| 435 |
), |
| 436 |
'get_purge_log' => array( |
| 437 |
'description' => 'Recent cache purges and what triggered each one. Use it to explain why a page stopped being cached. Read-only.', |
| 438 |
'inputSchema' => self::object_schema( |
| 439 |
array( |
| 440 |
'limit' => array( |
| 441 |
'type' => 'string', |
| 442 |
'description' => 'Max entries to return.', |
| 443 |
), |
| 444 |
), |
| 445 |
array() |
| 446 |
), |
| 447 |
'write' => false, |
| 448 |
'handler' => array( self::class, 'get_purge_log' ), |
| 449 |
), |
| 450 |
'recheck_rewrite_rules' => array( |
| 451 |
'description' => 'Re-verify the server rewrite rules that route requests to the cache, and repair them if they drifted.', |
| 452 |
'inputSchema' => self::object_schema( array(), array() ), |
| 453 |
'write' => true, |
| 454 |
'handler' => array( self::class, 'recheck_rewrite_rules' ), |
| 455 |
), |
| 456 |
'set_cloudflare_dev_mode' => array( |
| 457 |
'description' => 'Turn Cloudflare development mode on or off. On bypasses the edge cache for ~3 hours so origin changes show immediately.', |
| 458 |
'inputSchema' => self::object_schema( |
| 459 |
array( |
| 460 |
'enabled' => array( |
| 461 |
'type' => 'boolean', |
| 462 |
'description' => 'true turns development mode on, false turns it off.', |
| 463 |
), |
| 464 |
), |
| 465 |
array( 'enabled' ) |
| 466 |
), |
| 467 |
'write' => true, |
| 468 |
'handler' => array( self::class, 'set_cloudflare_dev_mode' ), |
| 469 |
), |
| 470 |
'optimize_database' => array( |
| 471 |
'description' => 'Run table optimization on the WordPress database (reclaims space after cleanup). Separate from clean_database, which deletes bloat rows.', |
| 472 |
'inputSchema' => self::object_schema( array(), array() ), |
| 473 |
'write' => true, |
| 474 |
'handler' => array( self::class, 'optimize_database' ), |
| 475 |
), |
| 476 |
'get_object_cache_status' => array( |
| 477 |
'description' => 'Object cache state: whether the drop-in is installed, which backend is configured, and the server snippet needed to enable it. Read-only.', |
| 478 |
'inputSchema' => self::object_schema( |
| 479 |
array( |
| 480 |
'detail' => array( |
| 481 |
'type' => 'string', |
| 482 |
'enum' => array( 'status', 'snippet' ), |
| 483 |
'description' => '"status" reports the current state; "snippet" returns the server config to enable it. Defaults to "status".', |
| 484 |
), |
| 485 |
), |
| 486 |
array() |
| 487 |
), |
| 488 |
'write' => false, |
| 489 |
'handler' => array( self::class, 'get_object_cache_status' ), |
| 490 |
), |
| 491 |
'toggle_object_cache' => array( |
| 492 |
'description' => 'Enable or disable the object cache drop-in. Verify the backend with test_object_cache first — enabling against an unreachable server slows every request.', |
| 493 |
'inputSchema' => self::object_schema( |
| 494 |
array( |
| 495 |
'enabled' => array( |
| 496 |
'type' => 'boolean', |
| 497 |
'description' => 'true installs the drop-in, false removes it.', |
| 498 |
), |
| 499 |
), |
| 500 |
array( 'enabled' ) |
| 501 |
), |
| 502 |
'write' => true, |
| 503 |
'handler' => array( self::class, 'toggle_object_cache' ), |
| 504 |
), |
| 505 |
'manage_critical_css' => array( |
| 506 |
'description' => 'List the stored Critical CSS entries, or clear them so they regenerate. Use generate_critical_css to create them.', |
| 507 |
'inputSchema' => self::object_schema( |
| 508 |
array( |
| 509 |
'action' => array( |
| 510 |
'type' => 'string', |
| 511 |
'enum' => array( 'list', 'clear' ), |
| 512 |
'description' => '"list" returns what is stored; "clear" deletes it.', |
| 513 |
), |
| 514 |
), |
| 515 |
array( 'action' ) |
| 516 |
), |
| 517 |
'write' => true, |
| 518 |
'handler' => array( self::class, 'manage_critical_css' ), |
| 519 |
), |
| 520 |
'get_preloader_status' => array( |
| 521 |
'description' => 'Cache preloader progress: whether a run is active, how far through the URL list it is. Read-only.', |
| 522 |
'inputSchema' => self::object_schema( array(), array() ), |
| 523 |
'write' => false, |
| 524 |
'handler' => array( self::class, 'get_preloader_status' ), |
| 525 |
), |
| 526 |
'stop_preloader' => array( |
| 527 |
'description' => 'Stop a running cache preload. Safe mid-run — already-warmed pages stay cached.', |
| 528 |
'inputSchema' => self::object_schema( array(), array() ), |
| 529 |
'write' => true, |
| 530 |
'handler' => array( self::class, 'stop_preloader' ), |
| 531 |
), |
| 532 |
'purge_url' => array( |
| 533 |
'description' => 'Purge the cache for ONE URL only (all its variants: device buckets, trailing-slash forms, static-tree copy). Surgical alternative to purge_cache when a single page changed.', |
| 534 |
'inputSchema' => self::object_schema( |
| 535 |
array( |
| 536 |
'url' => array( |
| 537 |
'type' => 'string', |
| 538 |
'description' => 'Absolute URL or site-relative path, e.g. "https://site.com/about/" or "/about/".', |
| 539 |
), |
| 540 |
), |
| 541 |
array( 'url' ) |
| 542 |
), |
| 543 |
'write' => true, |
| 544 |
'handler' => array( self::class, 'purge_url' ), |
| 545 |
), |
| 546 |
'test_object_cache' => array( |
| 547 |
'description' => 'Live connect + read/write probe of the configured Redis/Memcached backend using the saved Object Cache settings. Verifies the credentials actually work — writing settings alone does not.', |
| 548 |
'inputSchema' => self::object_schema( array(), array() ), |
| 549 |
'write' => false, |
| 550 |
'handler' => array( self::class, 'test_object_cache' ), |
| 551 |
), |
| 552 |
'cloudflare_verify' => array( |
| 553 |
'description' => 'Verify the saved Cloudflare credentials against the Cloudflare API (token/zone check). Read-only — use purge_cloudflare to purge the edge.', |
| 554 |
'inputSchema' => self::object_schema( array(), array() ), |
| 555 |
'write' => false, |
| 556 |
'handler' => array( self::class, 'cloudflare_verify' ), |
| 557 |
), |
| 558 |
'list_commands' => array( |
| 559 |
'description' => 'List every xSpeed command that run_command can invoke (name, description, module, options). Use this to discover the full action surface beyond the curated + dedicated tools.', |
| 560 |
'inputSchema' => self::object_schema( array(), array() ), |
| 561 |
'write' => false, |
| 562 |
'handler' => array( self::class, 'list_commands' ), |
| 563 |
), |
| 564 |
'run_command' => array( |
| 565 |
'description' => 'Run any xSpeed command — the full CLI surface (~50 commands across every module: cache, cloudflare, database, critical/unused CSS, pagespeed, images, migration, preloader, object cache, analytics, RUM, smart-* and more). Call list_commands first to discover names + options. Examples: run_command("cloudflare purge"), run_command("psi", {}, {"url":"https://site.com","strategy":"mobile"}). Permanently destructive commands ("database clean") additionally require a confirm_token from scan_database and are refused without one — this gateway is not a way around that confirmation.', |
| 566 |
'inputSchema' => self::object_schema( |
| 567 |
array( |
| 568 |
'command' => array( |
| 569 |
'type' => 'string', |
| 570 |
'description' => 'Command name, e.g. "cloudflare purge" or "database scan" (the "xspeed " prefix is optional).', |
| 571 |
), |
| 572 |
'args' => array( |
| 573 |
'type' => 'array', |
| 574 |
'description' => 'Positional arguments, if the command takes any.', |
| 575 |
'items' => array( 'type' => 'string' ), |
| 576 |
), |
| 577 |
'options' => array( |
| 578 |
'type' => 'object', |
| 579 |
'description' => 'Named options / flags, e.g. { "url": "https://site.com", "strategy": "mobile", "force": true }.', |
| 580 |
), |
| 581 |
'confirm_token' => array( |
| 582 |
'type' => 'string', |
| 583 |
'description' => 'Required ONLY for permanently destructive commands such as "database clean". Obtain it from scan_database, which previews exactly what would be deleted. Without it those commands are refused.', |
| 584 |
), |
| 585 |
), |
| 586 |
array( 'command' ) |
| 587 |
), |
| 588 |
'write' => true, |
| 589 |
'handler' => array( self::class, 'run_command' ), |
| 590 |
), |
| 591 |
); |
| 592 |
|
| 593 |
// Dedicated tools that wrap a command only present when a given |
| 594 |
// module is active (e.g. Pro): drop them if the command isn't |
| 595 |
// registered, so we never advertise a tool that always fails. The |
| 596 |
// action stays reachable via run_command if the command exists. |
| 597 |
$conditional = array( |
| 598 |
'generate_critical_css' => 'xspeed ccss', |
| 599 |
'purge_cloudflare' => 'xspeed cf', |
| 600 |
'cloudflare_verify' => 'xspeed cf', |
| 601 |
'flush_object_cache' => 'xspeed objcache', |
| 602 |
'test_object_cache' => 'xspeed objcache', |
| 603 |
'start_preloader' => 'xspeed preloader', |
| 604 |
'scan_database' => 'xspeed db', |
| 605 |
'clean_database' => 'xspeed db', |
| 606 |
'purge_url' => 'xspeed cache', |
| 607 |
'get_cache_inventory' => 'xspeed cache', |
| 608 |
'get_purge_log' => 'xspeed cache', |
| 609 |
'recheck_rewrite_rules' => 'xspeed cache', |
| 610 |
'set_cloudflare_dev_mode' => 'xspeed cf', |
| 611 |
'optimize_database' => 'xspeed db', |
| 612 |
'get_object_cache_status' => 'xspeed objcache', |
| 613 |
'toggle_object_cache' => 'xspeed objcache', |
| 614 |
'manage_critical_css' => 'xspeed ccss', |
| 615 |
'get_preloader_status' => 'xspeed preloader', |
| 616 |
'stop_preloader' => 'xspeed preloader', |
| 617 |
); |
| 618 |
|
| 619 |
/* |
| 620 |
* Tools that are ALWAYS in the catalog, mapped to the command they |
| 621 |
* cover. These are listed separately from $conditional because the |
| 622 |
* two roles are different and used to be conflated in one map: this |
| 623 |
* set only tells the alias generator "don't emit an alias for this |
| 624 |
* command, a typed tool already covers it" — it must never drop a |
| 625 |
* tool. |
| 626 |
* |
| 627 |
* That conflation is exactly what broke get_settings/update_settings: |
| 628 |
* they were mapped to `xspeed settings`, a command that did not exist, |
| 629 |
* so the drop loop unset them on every request and they never reached |
| 630 |
* tools/list. `xspeed settings` now exists (SettingsModule), but the |
| 631 |
* split is what stops the class of bug recurring — an unconditional |
| 632 |
* tool can no longer be removed by a command going away. (#149/#153) |
| 633 |
*/ |
| 634 |
$always = array( |
| 635 |
'get_settings' => 'xspeed settings', |
| 636 |
'update_settings' => 'xspeed settings', |
| 637 |
'run_pagespeed' => 'xspeed psi', |
| 638 |
'get_health' => 'xspeed health', |
| 639 |
'get_score_history' => 'xspeed score', |
| 640 |
'purge_cache' => 'xspeed purge', |
| 641 |
); |
| 642 |
|
| 643 |
$commands = Cli_Bridge::commands(); |
| 644 |
foreach ( $conditional as $tool => $command ) { |
| 645 |
if ( ! isset( $commands[ $command ] ) ) { |
| 646 |
unset( $catalog[ $tool ] ); |
| 647 |
} |
| 648 |
} |
| 649 |
// Alias generation reads both maps; the drop loop above reads only |
| 650 |
// $conditional. |
| 651 |
$conditional = array_merge( $conditional, $always ); |
| 652 |
|
| 653 |
/* |
| 654 |
* One dedicated tool per xSpeed CLI command, generated from the same |
| 655 |
* Cli_Bridge catalog the CLI registers from — so the AI can reach the |
| 656 |
* long tail without the list_commands -> run_command hop, and the |
| 657 |
* generated set can never drift from the CLI. |
| 658 |
* |
| 659 |
* Commands already covered by a typed tool above are SKIPPED. The |
| 660 |
* `isset()` guard below only catches NAME collisions, and a generated |
| 661 |
* name never collides — `xspeed cf` becomes `xspeed_cf`, which is not |
| 662 |
* `purge_cloudflare`. So both used to ship: two tools for one action, |
| 663 |
* with the generated one marked write even when it wrapped a read, |
| 664 |
* and a per-tool permission on one name silently bypassable via the |
| 665 |
* other. $conditional already maps every typed tool to its command; |
| 666 |
* inverted, that IS the skip list. |
| 667 |
*/ |
| 668 |
foreach ( self::cli_generated_tools( array_flip( $conditional ) ) as $name => $spec ) { |
| 669 |
if ( ! isset( $catalog[ $name ] ) ) { |
| 670 |
$catalog[ $name ] = $spec; |
| 671 |
} |
| 672 |
} |
| 673 |
|
| 674 |
return $catalog; |
| 675 |
} |
| 676 |
|
| 677 |
/** |
| 678 |
* Generate one MCP tool per registered xSpeed CLI command. Each wraps |
| 679 |
* Cli_Bridge::run(): the tool's `action` (the command's first positional, |
| 680 |
* e.g. `verify`/`purge` for `xspeed cf`) plus any named options are passed |
| 681 |
* straight through. Tool names are the command with the `xspeed ` prefix |
| 682 |
* dropped and spaces -> underscores (`xspeed cf` -> `xspeed_cf`). |
| 683 |
* |
| 684 |
* @return array<string, array{description:string, inputSchema:array, write:bool, handler:callable}> |
| 685 |
*/ |
| 686 |
private static function cli_generated_tools( array $covered = array() ): array { |
| 687 |
$tools = array(); |
| 688 |
foreach ( Cli_Bridge::commands() as $command => $spec ) { |
| 689 |
// Already exposed as typed tools with real schemas and honest |
| 690 |
// read/write kinds — generating a coarse alias too would give the |
| 691 |
// AI two ways to do one thing and make a per-tool permission on |
| 692 |
// the typed name bypassable via the generated one. |
| 693 |
if ( isset( $covered[ $command ] ) ) { |
| 694 |
continue; |
| 695 |
} |
| 696 |
$tool_name = self::cli_tool_name( $command ); |
| 697 |
if ( '' === $tool_name ) { |
| 698 |
continue; |
| 699 |
} |
| 700 |
|
| 701 |
// Build the input schema from the command's synopsis: positional |
| 702 |
// args become string properties (the first is usually the action, |
| 703 |
// exposed with its allowed values as an enum); assoc args become |
| 704 |
// named options. |
| 705 |
$properties = array(); |
| 706 |
$required = array(); |
| 707 |
foreach ( $spec['synopsis'] as $arg ) { |
| 708 |
if ( ! isset( $arg['name'] ) ) { |
| 709 |
continue; |
| 710 |
} |
| 711 |
$arg_name = (string) $arg['name']; |
| 712 |
$prop = array( |
| 713 |
'type' => 'string', |
| 714 |
'description' => isset( $arg['description'] ) ? (string) $arg['description'] : '', |
| 715 |
); |
| 716 |
if ( isset( $arg['options'] ) && is_array( $arg['options'] ) && ! empty( $arg['options'] ) ) { |
| 717 |
$prop['enum'] = array_values( array_map( 'strval', $arg['options'] ) ); |
| 718 |
} |
| 719 |
$properties[ $arg_name ] = $prop; |
| 720 |
$is_optional = ! empty( $arg['optional'] ); |
| 721 |
$is_flag = isset( $arg['type'] ) && 'flag' === $arg['type']; |
| 722 |
if ( ! $is_optional && ! $is_flag ) { |
| 723 |
$required[] = $arg_name; |
| 724 |
} |
| 725 |
} |
| 726 |
|
| 727 |
// Prefer the AI-facing hint. `shortdesc` is CLI help — written for |
| 728 |
// someone who already chose the command — so it says what the |
| 729 |
// output looks like, never when to reach for it. That is exactly |
| 730 |
// the question a model is answering when it reads tools/list, and |
| 731 |
// it is why 36 of these descriptions open with "Show". A module |
| 732 |
// that has not been given a hint yet keeps its shortdesc, so this |
| 733 |
// improves incrementally instead of needing all 40 at once. (#184) |
| 734 |
$description = '' !== ( $spec['ai_hint'] ?? '' ) |
| 735 |
? $spec['ai_hint'] |
| 736 |
: ( '' !== $spec['shortdesc'] |
| 737 |
? $spec['shortdesc'] |
| 738 |
: sprintf( 'Run the "%s" xSpeed command.', $command ) ); |
| 739 |
|
| 740 |
list( $write, $write_actions, $read_actions ) = self::cli_write_profile( $command, $spec['synopsis'] ); |
| 741 |
|
| 742 |
$tools[ $tool_name ] = array( |
| 743 |
'description' => $description, |
| 744 |
'inputSchema' => self::object_schema( $properties, $required ), |
| 745 |
'write' => $write, |
| 746 |
// The action values that mutate state. When set, read-only |
| 747 |
// enforcement is per-ACTION (a read-only grant may still call |
| 748 |
// the tool with a read action like "status"/"scan"). |
| 749 |
'write_actions' => $write_actions, |
| 750 |
// The complement — actions positively classified as reads. |
| 751 |
// action_writes() allowlists against THIS rather than negating |
| 752 |
// write_actions, so an action added to a command later is |
| 753 |
// refused under a read-only grant until it has been |
| 754 |
// classified, instead of silently becoming callable. |
| 755 |
'read_actions' => $read_actions, |
| 756 |
'handler' => self::cli_handler_for( $command, $spec['synopsis'] ), |
| 757 |
); |
| 758 |
} |
| 759 |
return $tools; |
| 760 |
} |
| 761 |
|
| 762 |
/** Derive an MCP tool name from a CLI command ("xspeed cf" -> "xspeed_cf"). */ |
| 763 |
private static function cli_tool_name( string $command ): string { |
| 764 |
$command = trim( preg_replace( '/\s+/', ' ', $command ) ?? '' ); |
| 765 |
if ( '' === $command ) { |
| 766 |
return ''; |
| 767 |
} |
| 768 |
return str_replace( ' ', '_', $command ); |
| 769 |
} |
| 770 |
|
| 771 |
/** Action verbs that only inspect state (never mutate). */ |
| 772 |
private const CLI_READ_VERBS = array( 'status', 'scan', 'list', 'verify', 'get', 'show', 'info', 'export', 'preview', 'check', 'snippet', 'test' ); |
| 773 |
|
| 774 |
/** Commands with NO action enum that are nonetheless pure inspection. */ |
| 775 |
private const CLI_READ_ONLY_COMMANDS = array( 'xspeed health', 'xspeed support' ); |
| 776 |
|
| 777 |
/** |
| 778 |
* Compute the write profile for a generated command tool: |
| 779 |
* [ $write_bool, $write_actions ] |
| 780 |
* where $write_actions is the list of action values that mutate state |
| 781 |
* (empty when the tool has no action enum). $write_bool is the tool-level |
| 782 |
* flag: true if ANY action writes (so read-only clients see it flagged), |
| 783 |
* but per-action enforcement in invoke() still lets a read-only grant run |
| 784 |
* the tool's read actions (e.g. `minify status` while `minify purge` is |
| 785 |
* refused). |
| 786 |
* |
| 787 |
* @param string $command Full command name. |
| 788 |
* @param array $synopsis Command synopsis. |
| 789 |
* @return array{0:bool,1:string[],2:string[]} write flag, write actions, read actions |
| 790 |
*/ |
| 791 |
/** |
| 792 |
* Does THIS call mutate state, given the action the caller submitted? |
| 793 |
* |
| 794 |
* The tool-level `write` flag is true when ANY of a command's actions |
| 795 |
* write, so read-only clients can see the tool is capable of mutating. |
| 796 |
* Enforcing on that flag alone refuses the whole tool — which is how a |
| 797 |
* read-only grant lost the ability to run `xspeed_minify status` even |
| 798 |
* though only `purge` writes. `write_actions` records exactly which |
| 799 |
* action values mutate; this is what reads it. |
| 800 |
* |
| 801 |
* Fails CLOSED in every ambiguous case. An action that isn't in the |
| 802 |
* schema, an absent action, or a tool with no per-action profile all fall |
| 803 |
* back to the coarse flag and are refused. A read-only grant may end up |
| 804 |
* with less access than strictly necessary; it must never end up with |
| 805 |
* more. |
| 806 |
* |
| 807 |
* @param array $tool The catalog entry. |
| 808 |
* @param array $args The submitted arguments. |
| 809 |
*/ |
| 810 |
private static function action_writes( array $tool, array $args ): bool { |
| 811 |
$write_actions = isset( $tool['write_actions'] ) && is_array( $tool['write_actions'] ) |
| 812 |
? $tool['write_actions'] |
| 813 |
: array(); |
| 814 |
|
| 815 |
// No per-action profile — the coarse flag is all we have. |
| 816 |
if ( empty( $write_actions ) ) { |
| 817 |
return true; |
| 818 |
} |
| 819 |
|
| 820 |
$action = isset( $args['action'] ) && is_scalar( $args['action'] ) |
| 821 |
? strtolower( trim( (string) $args['action'] ) ) |
| 822 |
: ''; |
| 823 |
|
| 824 |
// No action supplied: the command's own default is unknown here, so |
| 825 |
// treat it as a write rather than guessing. |
| 826 |
if ( '' === $action ) { |
| 827 |
return true; |
| 828 |
} |
| 829 |
|
| 830 |
// Only an action we positively recognise as read is allowed through. |
| 831 |
// Anything unknown is refused, so a future action added to a command |
| 832 |
// can't silently become callable under a read-only grant before it has |
| 833 |
// been classified. |
| 834 |
$known = array_map( |
| 835 |
static function ( $a ) { |
| 836 |
return strtolower( trim( (string) $a ) ); |
| 837 |
}, |
| 838 |
isset( $tool['read_actions'] ) && is_array( $tool['read_actions'] ) ? $tool['read_actions'] : array() |
| 839 |
); |
| 840 |
|
| 841 |
return ! in_array( $action, $known, true ); |
| 842 |
} |
| 843 |
|
| 844 |
private static function cli_write_profile( string $command, array $synopsis ): array { |
| 845 |
// Command with an action enum → classify each action. |
| 846 |
foreach ( $synopsis as $arg ) { |
| 847 |
if ( isset( $arg['type'], $arg['options'] ) && 'positional' === $arg['type'] && is_array( $arg['options'] ) ) { |
| 848 |
$write_actions = array(); |
| 849 |
$read_actions = array(); |
| 850 |
foreach ( $arg['options'] as $opt ) { |
| 851 |
if ( in_array( strtolower( (string) $opt ), self::CLI_READ_VERBS, true ) ) { |
| 852 |
$read_actions[] = (string) $opt; |
| 853 |
} else { |
| 854 |
$write_actions[] = (string) $opt; |
| 855 |
} |
| 856 |
} |
| 857 |
return array( ! empty( $write_actions ), $write_actions, $read_actions ); |
| 858 |
} |
| 859 |
} |
| 860 |
|
| 861 |
// No action enum: a small allow-list of pure-inspection commands is |
| 862 |
// read-only; everything else defaults to write (safe — a read-only |
| 863 |
// grant never mutates). |
| 864 |
$is_read = in_array( trim( $command ), self::CLI_READ_ONLY_COMMANDS, true ); |
| 865 |
return array( ! $is_read, array(), array() ); |
| 866 |
} |
| 867 |
|
| 868 |
/** |
| 869 |
* Build the handler for a generated command tool. It maps the tool's |
| 870 |
* arguments back to Cli_Bridge::run(): positional synopsis args (in order) |
| 871 |
* become $args; everything else is passed as named options. |
| 872 |
* |
| 873 |
* @param string $command Full command name. |
| 874 |
* @param array $synopsis Command synopsis. |
| 875 |
* @return callable |
| 876 |
*/ |
| 877 |
private static function cli_handler_for( string $command, array $synopsis ): callable { |
| 878 |
// Names of the positional args, in declared order. |
| 879 |
$positionals = array(); |
| 880 |
foreach ( $synopsis as $arg ) { |
| 881 |
if ( isset( $arg['name'] ) && ( ! isset( $arg['type'] ) || 'positional' === $arg['type'] ) ) { |
| 882 |
$positionals[] = (string) $arg['name']; |
| 883 |
} |
| 884 |
} |
| 885 |
|
| 886 |
return static function ( array $tool_args ) use ( $command, $positionals ) { |
| 887 |
$args = array(); |
| 888 |
$assoc = $tool_args; |
| 889 |
// Pull positionals out (in order) into $args; the rest are options. |
| 890 |
foreach ( $positionals as $pname ) { |
| 891 |
if ( array_key_exists( $pname, $assoc ) && '' !== (string) $assoc[ $pname ] ) { |
| 892 |
$args[] = (string) $assoc[ $pname ]; |
| 893 |
} |
| 894 |
unset( $assoc[ $pname ] ); |
| 895 |
} |
| 896 |
return Cli_Bridge::run( $command, $args, $assoc ); |
| 897 |
}; |
| 898 |
} |
| 899 |
|
| 900 |
/** |
| 901 |
* The tool list in MCP `tools/list` shape. |
| 902 |
* |
| 903 |
* @return array<int, array{name:string, description:string, inputSchema:array}> |
| 904 |
*/ |
| 905 |
public static function list(): array { |
| 906 |
$out = array(); |
| 907 |
foreach ( self::catalog() as $name => $spec ) { |
| 908 |
$out[] = array( |
| 909 |
'name' => $name, |
| 910 |
'description' => $spec['description'], |
| 911 |
'inputSchema' => $spec['inputSchema'], |
| 912 |
); |
| 913 |
} |
| 914 |
return $out; |
| 915 |
} |
| 916 |
|
| 917 |
/** |
| 918 |
* Invoke a tool by name with decoded arguments. |
| 919 |
* |
| 920 |
* @param string $name Tool name. |
| 921 |
* @param array $args Decoded arguments. |
| 922 |
* @return array|\WP_Error Result payload or error. |
| 923 |
*/ |
| 924 |
public static function invoke( string $name, array $args ) { |
| 925 |
$catalog = self::catalog(); |
| 926 |
if ( ! isset( $catalog[ $name ] ) ) { |
| 927 |
$error = new \WP_Error( |
| 928 |
'xspeed_mcp_unknown_tool', |
| 929 |
sprintf( |
| 930 |
/* translators: %s: tool name. */ |
| 931 |
__( 'Unknown tool: %s', 'xspeed' ), |
| 932 |
$name |
| 933 |
), |
| 934 |
array( 'status' => 404 ) |
| 935 |
); |
| 936 |
|
| 937 |
// A call for a tool that doesn't exist is still something that |
| 938 |
// happened to this site, and a run of them is the shape of a |
| 939 |
// probe. Recording it is the difference between a trail that |
| 940 |
// shows what was ATTEMPTED and one that only shows what |
| 941 |
// succeeded. Scope is unknowable here, so log the conservative |
| 942 |
// one rather than implying the attempt was read-only. |
| 943 |
Mcp_Activity_Log::record( $name, $args, false, $error->get_error_message(), 'write', self::$channel ); |
| 944 |
|
| 945 |
return $error; |
| 946 |
} |
| 947 |
|
| 948 |
// Scope enforcement: a read-only connection cannot invoke a tool that |
| 949 |
// mutates state. run_command is a gateway to the full CLI surface, so |
| 950 |
// it's treated as write regardless of the wrapped command. The active |
| 951 |
// credential's scope (pairing token OR OAuth access token) is carried |
| 952 |
// in self::$scope_override; it falls back to the pairing global for |
| 953 |
// callers that don't set a per-call scope. |
| 954 |
if ( ! empty( $catalog[ $name ]['write'] ) && self::is_read_only() && self::action_writes( $catalog[ $name ], $args ) ) { |
| 955 |
return new \WP_Error( |
| 956 |
'xspeed_mcp_read_only', |
| 957 |
sprintf( |
| 958 |
/* translators: %s: tool name. */ |
| 959 |
__( 'This MCP connection is read-only; the "%s" tool changes state and is not permitted. Reconnect with write access to use it.', 'xspeed' ), |
| 960 |
$name |
| 961 |
), |
| 962 |
array( 'status' => 403 ) |
| 963 |
); |
| 964 |
} |
| 965 |
|
| 966 |
/* |
| 967 |
* Scan-before-clean, enforced at the dispatcher rather than in one |
| 968 |
* handler. |
| 969 |
* |
| 970 |
* The guard used to live inside clean_database(). That protected a |
| 971 |
* TOOL NAME, not the action: run_command("db", ["clean"]) reaches the |
| 972 |
* same Cli_Bridge::run('db', ['clean']) with no token, no preview and |
| 973 |
* no warning, and list_commands advertises the route to the assistant |
| 974 |
* in plainer words ("Scan or clean WordPress bloat") than the tool |
| 975 |
* that just refused it. Measured on a live site, that second door |
| 976 |
* permanently destroyed 3,007 rows in a single call and reported |
| 977 |
* success. |
| 978 |
* |
| 979 |
* Every tool passes through invoke(), so a confirmation checked here |
| 980 |
* covers each door at once — including any future tool that wraps the |
| 981 |
* same command. (#184) |
| 982 |
*/ |
| 983 |
$destructive = self::destructive_action( $name, $args ); |
| 984 |
if ( '' !== $destructive ) { |
| 985 |
$confirmed = self::verify_clean_token( $args ); |
| 986 |
if ( is_wp_error( $confirmed ) ) { |
| 987 |
Mcp_Activity_Log::record( $name, $args, false, $confirmed->get_error_message(), 'write', self::$channel ); |
| 988 |
return $confirmed; |
| 989 |
} |
| 990 |
} |
| 991 |
|
| 992 |
self::$dispatching = true; |
| 993 |
try { |
| 994 |
$result = call_user_func( $catalog[ $name ]['handler'], $args ); |
| 995 |
|
| 996 |
// Audit every dispatched call — this is the record the admin |
| 997 |
// reads to answer "what did the assistant do to my site?". |
| 998 |
// Recorded here (not per-handler) so a new tool is covered the |
| 999 |
// moment it joins the catalog. |
| 1000 |
[ $ok, $error ] = self::outcome( $result ); |
| 1001 |
|
| 1002 |
$scope = empty( $catalog[ $name ]['write'] ) ? 'read' : 'write'; |
| 1003 |
|
| 1004 |
Mcp_Activity_Log::record( $name, $args, $ok, $error, $scope, self::$channel ); |
| 1005 |
|
| 1006 |
return $result; |
| 1007 |
} finally { |
| 1008 |
self::$dispatching = false; |
| 1009 |
} |
| 1010 |
} |
| 1011 |
|
| 1012 |
/** |
| 1013 |
* Read success/failure out of a handler result. |
| 1014 |
* |
| 1015 |
* Two failure shapes reach here. A handler that validates its own |
| 1016 |
* input returns WP_Error. A handler that delegates to Cli_Bridge gets |
| 1017 |
* back an ARRAY carrying `ok => false` plus `error`, because a |
| 1018 |
* `WP_CLI::error()` inside the shim is a controlled failure rather |
| 1019 |
* than an exception. Reading only the first shape logged every failed |
| 1020 |
* command — a refused purge, a Cloudflare call with no credentials — |
| 1021 |
* as a success. |
| 1022 |
* |
| 1023 |
* @param mixed $result Handler return value. |
| 1024 |
* @return array{0:bool,1:string} |
| 1025 |
*/ |
| 1026 |
private static function outcome( $result ): array { |
| 1027 |
if ( is_wp_error( $result ) ) { |
| 1028 |
return array( false, $result->get_error_message() ); |
| 1029 |
} |
| 1030 |
|
| 1031 |
if ( is_array( $result ) && array_key_exists( 'ok', $result ) && ! $result['ok'] ) { |
| 1032 |
$error = isset( $result['error'] ) ? (string) $result['error'] : ''; |
| 1033 |
return array( false, '' === $error ? 'Command reported failure.' : $error ); |
| 1034 |
} |
| 1035 |
|
| 1036 |
return array( true, '' ); |
| 1037 |
} |
| 1038 |
|
| 1039 |
/** @var string Transport that carried the current call (for the audit log). */ |
| 1040 |
private static $channel = 'mcp'; |
| 1041 |
|
| 1042 |
/** |
| 1043 |
* Name the transport for subsequent invokes — the JSON-RPC endpoint and |
| 1044 |
* the hosted-broker REST routes share this catalog, and the audit trail |
| 1045 |
* should say which one a call arrived on. |
| 1046 |
*/ |
| 1047 |
public static function set_channel( string $channel ): void { |
| 1048 |
self::$channel = '' === $channel ? 'mcp' : $channel; |
| 1049 |
} |
| 1050 |
|
| 1051 |
/** @var bool True while an MCP tool handler is executing. */ |
| 1052 |
private static $dispatching = false; |
| 1053 |
|
| 1054 |
/** |
| 1055 |
* True while a tool call is being dispatched — lets deeper layers |
| 1056 |
* (e.g. the settings change-log) attribute a mutation to MCP. |
| 1057 |
*/ |
| 1058 |
public static function in_dispatch(): bool { |
| 1059 |
return self::$dispatching; |
| 1060 |
} |
| 1061 |
|
| 1062 |
/* |
| 1063 |
* Handlers — thin proxies to the Free engine. Each takes decoded tool |
| 1064 |
* arguments and returns an array payload (or WP_Error on bad input). |
| 1065 |
*/ |
| 1066 |
|
| 1067 |
/** |
| 1068 |
* Cache status, stats, and detected server. |
| 1069 |
* |
| 1070 |
* @param array $args Unused. |
| 1071 |
* @return array |
| 1072 |
*/ |
| 1073 |
public static function get_cache_status( array $args ) { |
| 1074 |
unset( $args ); |
| 1075 |
$opts = Settings::get(); |
| 1076 |
return array( |
| 1077 |
'cache_enabled' => (bool) ( $opts['cache_enabled'] ?? false ), |
| 1078 |
'stats' => Cache::get_stats(), |
| 1079 |
'server' => Server::type(), |
| 1080 |
); |
| 1081 |
} |
| 1082 |
|
| 1083 |
/** |
| 1084 |
* Facts about this site and install, stated explicitly. |
| 1085 |
* |
| 1086 |
* The Hub's fleet dashboard needed two things no tool reported directly. |
| 1087 |
* It had been INFERRING Pro's presence from `list_modules` — "any entry |
| 1088 |
* with tier: pro" — which works only because the registry returns just |
| 1089 |
* available modules. That is an inference riding an implementation |
| 1090 |
* detail, and it breaks the day a Pro install registers zero Pro modules. |
| 1091 |
* |
| 1092 |
* `pro_active` is "the Pro plugin is loaded and API-compatible"; |
| 1093 |
* `licensed` is a separate question, since Pro can be active but |
| 1094 |
* unlicensed (its modules then boot but their settings are locked). Both |
| 1095 |
* are reported so a consumer never has to guess which one it wanted. |
| 1096 |
* (#146) |
| 1097 |
* |
| 1098 |
* @param array $args Unused. |
| 1099 |
* @return array |
| 1100 |
*/ |
| 1101 |
/** |
| 1102 |
* Is Pro licensed right now? |
| 1103 |
* |
| 1104 |
* Resolved through the `xspeed_module_descriptor` filter — the one Pro |
| 1105 |
* actually registers — by running a minimal Pro descriptor through it and |
| 1106 |
* reading back the `locked` flag Pro sets when the licence is inactive. |
| 1107 |
* |
| 1108 |
* `license` is deliberately not used as the probe slug: Pro exempts that |
| 1109 |
* module from locking so an expired site can still reach the screen where |
| 1110 |
* a new key is entered, so it would always come back unlocked. |
| 1111 |
*/ |
| 1112 |
private static function pro_licensed(): bool { |
| 1113 |
$probe = apply_filters( |
| 1114 |
'xspeed_module_descriptor', |
| 1115 |
array( |
| 1116 |
'slug' => '__license_probe__', |
| 1117 |
'tier' => 'pro', |
| 1118 |
), |
| 1119 |
null |
| 1120 |
); |
| 1121 |
|
| 1122 |
return empty( $probe['locked'] ); |
| 1123 |
} |
| 1124 |
|
| 1125 |
public static function get_site_info( array $args ) { |
| 1126 |
unset( $args ); |
| 1127 |
|
| 1128 |
$pro_active = Tier_Registry::pro_active(); |
| 1129 |
|
| 1130 |
return array( |
| 1131 |
'pro_active' => $pro_active, |
| 1132 |
'pro_version' => defined( 'XSPEED_PRO_VERSION' ) ? (string) constant( 'XSPEED_PRO_VERSION' ) : null, |
| 1133 |
// Distinct from pro_active: Pro can be installed and running |
| 1134 |
// while its license is expired or absent. |
| 1135 |
// |
| 1136 |
// NOT `apply_filters( 'xspeed_pro_licensed', true )`. That hook is |
| 1137 |
// only ever APPLIED by Pro as an override point — no released |
| 1138 |
// version registers it — so with nothing listening the `true` |
| 1139 |
// default stood and this reported `licensed: true` on a fully |
| 1140 |
// revoked licence: the exact misreport the tool exists to |
| 1141 |
// eliminate. (QA blocker on #158) |
| 1142 |
// |
| 1143 |
// Ask the question the dashboard asks instead. Pro DOES register |
| 1144 |
// `xspeed_module_descriptor` and stamps `locked => 'license'` on |
| 1145 |
// every Pro entry when the licence is inactive, so reading that |
| 1146 |
// back is a real signal, and it cannot drift from what the panel |
| 1147 |
// shows because it IS what the panel shows. |
| 1148 |
'licensed' => $pro_active ? self::pro_licensed() : false, |
| 1149 |
'plugin_version' => defined( 'XSPEED_VERSION' ) ? (string) constant( 'XSPEED_VERSION' ) : null, |
| 1150 |
'wp_version' => get_bloginfo( 'version' ), |
| 1151 |
'php_version' => PHP_VERSION, |
| 1152 |
'server' => Server::type(), |
| 1153 |
'multisite' => is_multisite(), |
| 1154 |
); |
| 1155 |
} |
| 1156 |
|
| 1157 |
/** |
| 1158 |
* All registered module descriptors. |
| 1159 |
* |
| 1160 |
* @param array $args Unused. |
| 1161 |
* @return array |
| 1162 |
*/ |
| 1163 |
public static function list_modules( array $args ) { |
| 1164 |
unset( $args ); |
| 1165 |
return Admin::modules_payload(); |
| 1166 |
} |
| 1167 |
|
| 1168 |
/** |
| 1169 |
* Run the optimization autopilot. |
| 1170 |
* |
| 1171 |
* A thin wrapper: everything — the plan, the verification, the revert — |
| 1172 |
* lives in Optimize_Runner, so the CLI and this tool cannot drift into |
| 1173 |
* making different decisions about the same site. |
| 1174 |
* |
| 1175 |
* @param array<string,mixed> $args Tool arguments. |
| 1176 |
* @return array<string,mixed>|\WP_Error |
| 1177 |
*/ |
| 1178 |
public static function optimize_site( array $args = array() ) { |
| 1179 |
$run = array( |
| 1180 |
'aggressiveness' => (string) ( $args['aggressiveness'] ?? 'standard' ), |
| 1181 |
'dry_run' => (bool) ( $args['dry_run'] ?? false ), |
| 1182 |
'measure_score' => (string) ( $args['measure_score'] ?? 'auto' ), |
| 1183 |
); |
| 1184 |
|
| 1185 |
// Tuning arguments are forwarded ONLY when present, and are not |
| 1186 |
// understood by Free — a listener on `xspeed_optimize_report` reads |
| 1187 |
// them from that filter's `$context`. Passing them through rather |
| 1188 |
// than naming them in the array above is deliberate: this handler |
| 1189 |
// builds an explicit whitelist, so an argument it does not list is |
| 1190 |
// silently dropped. A caller asking to reach a score would have got a |
| 1191 |
// single pass and a success response — wrong behaviour with no error, |
| 1192 |
// which is the expensive kind to diagnose. |
| 1193 |
foreach ( array( 'target_score', 'max_rounds' ) as $key ) { |
| 1194 |
if ( isset( $args[ $key ] ) && is_numeric( $args[ $key ] ) ) { |
| 1195 |
$run[ $key ] = (int) $args[ $key ]; |
| 1196 |
} |
| 1197 |
} |
| 1198 |
|
| 1199 |
return \XSpeed\Optimize_Runner::run( $run ); |
| 1200 |
} |
| 1201 |
|
| 1202 |
/** |
| 1203 |
* Before/after cache benchmark timings. |
| 1204 |
* |
| 1205 |
* @param array $args Unused. |
| 1206 |
* @return array |
| 1207 |
*/ |
| 1208 |
public static function run_benchmark( array $args ) { |
| 1209 |
unset( $args ); |
| 1210 |
return Cache_Benchmark::run(); |
| 1211 |
} |
| 1212 |
|
| 1213 |
/** |
| 1214 |
* Personalized Pro-feature suggestions for this site. |
| 1215 |
* |
| 1216 |
* @param array $args Unused. |
| 1217 |
* @return array |
| 1218 |
*/ |
| 1219 |
public static function get_pro_audit( array $args ) { |
| 1220 |
unset( $args ); |
| 1221 |
return array( 'suggestions' => Pro_Audit::run() ); |
| 1222 |
} |
| 1223 |
|
| 1224 |
/** |
| 1225 |
* Purge the cache by type. |
| 1226 |
* |
| 1227 |
* @param array $args { type?:string } — one of PURGE_TYPES; default all. |
| 1228 |
* @return array|\WP_Error |
| 1229 |
*/ |
| 1230 |
public static function purge_cache( array $args ) { |
| 1231 |
$type = isset( $args['type'] ) ? (string) $args['type'] : 'all'; |
| 1232 |
if ( '' === $type ) { |
| 1233 |
$type = 'all'; |
| 1234 |
} |
| 1235 |
if ( ! in_array( $type, self::PURGE_TYPES, true ) ) { |
| 1236 |
return new \WP_Error( |
| 1237 |
'xspeed_mcp_bad_type', |
| 1238 |
sprintf( |
| 1239 |
/* translators: %s: comma-separated list of valid purge types. */ |
| 1240 |
__( 'Invalid purge type. Expected one of: %s', 'xspeed' ), |
| 1241 |
implode( ', ', self::PURGE_TYPES ) |
| 1242 |
), |
| 1243 |
array( 'status' => 400 ) |
| 1244 |
); |
| 1245 |
} |
| 1246 |
// Named source, not the default "manual": the purge log's whole job |
| 1247 |
// is to let an admin see that the cache cleared because an assistant |
| 1248 |
// asked, not because someone clicked. |
| 1249 |
$cause = __( 'AI assistant', 'xspeed' ); |
| 1250 |
|
| 1251 |
/* |
| 1252 |
* `page`, `assets` and `rest` are fine-grained slices of the local |
| 1253 |
* sweep with no target of their own, and they predate this tool's |
| 1254 |
* per-store report — an assistant asking for `page` means the HTML, |
| 1255 |
* not the HTML plus the minified bundles plus every purge listener. |
| 1256 |
* They stay on purge_type() so their meaning does not change under |
| 1257 |
* callers already relying on it. |
| 1258 |
* |
| 1259 |
* Everything else routes through the runner — the same core function |
| 1260 |
* the CLI and the REST callback use — so an assistant told "cache |
| 1261 |
* cleared" is reading the same per-store verdict a human would get, |
| 1262 |
* including a Cloudflare zone that refused the purge. |
| 1263 |
*/ |
| 1264 |
if ( in_array( $type, array( 'page', 'assets', 'rest' ), true ) ) { |
| 1265 |
return array( |
| 1266 |
'purged' => $type, |
| 1267 |
'count' => Cache::purge_type( $type, $cause ), |
| 1268 |
'ok' => true, |
| 1269 |
'stats' => Cache::get_stats(), |
| 1270 |
); |
| 1271 |
} |
| 1272 |
|
| 1273 |
$report = \XSpeed\Purge_Runner::run( array( $type ), $cause ); |
| 1274 |
$count = 0; |
| 1275 |
foreach ( $report['types'] as $row ) { |
| 1276 |
$count += (int) $row['entries']; |
| 1277 |
} |
| 1278 |
|
| 1279 |
return array( |
| 1280 |
'purged' => $type, |
| 1281 |
'count' => $count, |
| 1282 |
'ok' => $report['ok'], |
| 1283 |
'report' => $report['types'], |
| 1284 |
'stats' => Cache::get_stats(), |
| 1285 |
); |
| 1286 |
} |
| 1287 |
|
| 1288 |
/** |
| 1289 |
* Enable or disable page caching. |
| 1290 |
* |
| 1291 |
* @param array $args { enabled:bool }. |
| 1292 |
* @return array|\WP_Error |
| 1293 |
*/ |
| 1294 |
public static function toggle_cache( array $args ) { |
| 1295 |
if ( ! array_key_exists( 'enabled', $args ) ) { |
| 1296 |
return new \WP_Error( |
| 1297 |
'xspeed_mcp_missing_enabled', |
| 1298 |
__( 'The "enabled" parameter is required (true or false).', 'xspeed' ), |
| 1299 |
array( 'status' => 400 ) |
| 1300 |
); |
| 1301 |
} |
| 1302 |
$enabled = rest_sanitize_boolean( $args['enabled'] ); |
| 1303 |
$install = Cache::toggle( $enabled ); |
| 1304 |
|
| 1305 |
// Persist cache_enabled the same way the Free /cache/toggle route |
| 1306 |
// does (class-rest-api.php:235) — Cache::toggle handles the drop-in |
| 1307 |
// + wp-config; Settings owns the option flag. |
| 1308 |
// |
| 1309 |
// From the RESULT, not from $enabled: toggle() refuses to enable when |
| 1310 |
// another caching plugin owns the drop-in, and writing the requested |
| 1311 |
// value regardless left the site reporting a cache it had not |
| 1312 |
// installed — over MCP, with no human reading the response. |
| 1313 |
|
| 1314 |
return array( |
| 1315 |
'cache_enabled' => $install['enabled'], |
| 1316 |
'blocked' => ! empty( $install['blocked'] ), |
| 1317 |
'blocked_reason' => $install['blocked_reason'] ?? null, |
| 1318 |
'install_state' => $install, |
| 1319 |
'stats' => Cache::get_stats(), |
| 1320 |
); |
| 1321 |
} |
| 1322 |
|
| 1323 |
/** |
| 1324 |
* Is this module reachable over MCP right now? |
| 1325 |
* |
| 1326 |
* Mirrors SettingsModule::module_reachable(). Registration is not |
| 1327 |
* enough: Module_Registry::available() only asks whether Pro is LOADED, |
| 1328 |
* not whether it is LICENSED, so an unlicensed Pro site had every Pro |
| 1329 |
* module readable and writable over MCP while the dashboard showed it |
| 1330 |
* locked — reachable by any agent holding a write token. (QA M2) |
| 1331 |
* |
| 1332 |
* The licence answer comes through the `xspeed_module_descriptor` filter |
| 1333 |
* Pro registers, so Free never names a Pro class. (NOT |
| 1334 |
* `xspeed_pro_licensed` — Pro only ever APPLIES that one as an override |
| 1335 |
* and nothing listens to it, so gating on it silently passed everything.) |
| 1336 |
* `license` is exempt for the same reason Pro exempts it: locking it |
| 1337 |
* would remove the only surface that can fix an expired licence. |
| 1338 |
*/ |
| 1339 |
private static function settings_module_reachable( string $slug ): bool { |
| 1340 |
$module = \XSpeed\Module_Registry::available()[ $slug ] ?? null; |
| 1341 |
if ( ! $module ) { |
| 1342 |
return false; |
| 1343 |
} |
| 1344 |
if ( \XSpeed\Module::TIER_PRO !== $module->tier() || 'license' === $slug ) { |
| 1345 |
return true; |
| 1346 |
} |
| 1347 |
|
| 1348 |
// Ask the SAME question the dashboard asks. `xspeed_pro_licensed` is |
| 1349 |
// only ever APPLIED by Pro as an override hook — nothing registers it |
| 1350 |
// — so calling it here returned the default `true` and gated nothing. |
| 1351 |
// Pro DOES register `xspeed_module_descriptor`, and sets |
| 1352 |
// `locked => 'license'` on every Pro entry when the licence is |
| 1353 |
// inactive. Reusing that keeps one definition of "locked" instead of |
| 1354 |
// a second one in Free that can drift from the panel. (QA M2) |
| 1355 |
$entry = apply_filters( |
| 1356 |
'xspeed_module_descriptor', |
| 1357 |
array( |
| 1358 |
'slug' => $slug, |
| 1359 |
'tier' => $module->tier(), |
| 1360 |
), |
| 1361 |
$module |
| 1362 |
); |
| 1363 |
|
| 1364 |
return empty( $entry['locked'] ); |
| 1365 |
} |
| 1366 |
|
| 1367 |
/** |
| 1368 |
* Read a module's schema-validated settings. |
| 1369 |
* |
| 1370 |
* @param array $args { module:string }. |
| 1371 |
* @return array|\WP_Error |
| 1372 |
*/ |
| 1373 |
public static function get_settings( array $args ) { |
| 1374 |
$module = isset( $args['module'] ) ? (string) $args['module'] : ''; |
| 1375 |
if ( '' === $module ) { |
| 1376 |
return new \WP_Error( |
| 1377 |
'xspeed_mcp_missing_module', |
| 1378 |
__( 'The "module" parameter is required.', 'xspeed' ), |
| 1379 |
array( 'status' => 400 ) |
| 1380 |
); |
| 1381 |
} |
| 1382 |
if ( ! self::settings_module_reachable( $module ) ) { |
| 1383 |
return new \WP_Error( |
| 1384 |
'xspeed_mcp_unknown_module', |
| 1385 |
sprintf( |
| 1386 |
/* translators: %s: module slug. */ |
| 1387 |
__( 'Unknown module "%s".', 'xspeed' ), |
| 1388 |
$module |
| 1389 |
), |
| 1390 |
array( 'status' => 404 ) |
| 1391 |
); |
| 1392 |
} |
| 1393 |
/** |
| 1394 |
* Filter the get_settings MCP payload for one module. |
| 1395 |
* |
| 1396 |
* Lets the module that owns the settings attach state the stored |
| 1397 |
* values alone cannot express — a toggle that is on but resolves to |
| 1398 |
* no effect on this host (Brotli without ngx_brotli), a configured |
| 1399 |
* generator that has never succeeded. Free never names Pro classes, |
| 1400 |
* so this seam is how a Pro module reaches the response an agent |
| 1401 |
* reads. |
| 1402 |
* |
| 1403 |
* @param array<string,mixed> $payload The response: module + settings. |
| 1404 |
* @param string $module Module slug. |
| 1405 |
* @param string $action 'get' here; 'update' on writes. |
| 1406 |
*/ |
| 1407 |
return apply_filters( |
| 1408 |
'xspeed_mcp_settings_payload', |
| 1409 |
array( |
| 1410 |
'module' => $module, |
| 1411 |
// Public view — secret fields masked. An MCP agent must never be able |
| 1412 |
// to read stored credentials back in plaintext. (#115) |
| 1413 |
'settings' => Settings_Manager::get_public( $module ), |
| 1414 |
), |
| 1415 |
$module, |
| 1416 |
'get' |
| 1417 |
); |
| 1418 |
} |
| 1419 |
|
| 1420 |
/** |
| 1421 |
* Update a module's settings (schema-validated). |
| 1422 |
* |
| 1423 |
* @param array $args { module:string, values:array }. |
| 1424 |
* @return array|\WP_Error |
| 1425 |
*/ |
| 1426 |
public static function update_settings( array $args ) { |
| 1427 |
$module = isset( $args['module'] ) ? (string) $args['module'] : ''; |
| 1428 |
$values = $args['values'] ?? null; |
| 1429 |
if ( '' === $module ) { |
| 1430 |
return new \WP_Error( |
| 1431 |
'xspeed_mcp_missing_module', |
| 1432 |
__( 'The "module" parameter is required.', 'xspeed' ), |
| 1433 |
array( 'status' => 400 ) |
| 1434 |
); |
| 1435 |
} |
| 1436 |
if ( ! is_array( $values ) ) { |
| 1437 |
return new \WP_Error( |
| 1438 |
'xspeed_mcp_bad_values', |
| 1439 |
__( 'The "values" parameter must be an object of setting keys.', 'xspeed' ), |
| 1440 |
array( 'status' => 400 ) |
| 1441 |
); |
| 1442 |
} |
| 1443 |
if ( ! self::settings_module_reachable( $module ) ) { |
| 1444 |
return new \WP_Error( |
| 1445 |
'xspeed_mcp_unknown_module', |
| 1446 |
sprintf( |
| 1447 |
/* translators: %s: module slug. */ |
| 1448 |
__( 'Unknown module "%s".', 'xspeed' ), |
| 1449 |
$module |
| 1450 |
), |
| 1451 |
array( 'status' => 404 ) |
| 1452 |
); |
| 1453 |
} |
| 1454 |
// Writing credentials over MCP requires the explicit `configure` grant — |
| 1455 |
// off by default even for a write-scoped connection — so an agent can't |
| 1456 |
// silently repoint the Cloudflare/object-cache backend at an attacker |
| 1457 |
// endpoint. Refuse with a message naming exactly which fields need it. |
| 1458 |
// (Settings_Manager::update also strips these as a backstop covering the |
| 1459 |
// run_command → CLI path.) (#116) |
| 1460 |
if ( ! self::can_configure() ) { |
| 1461 |
$secret_fields = Settings_Manager::secret_keys_in( $module, $values ); |
| 1462 |
if ( ! empty( $secret_fields ) ) { |
| 1463 |
return new \WP_Error( |
| 1464 |
'xspeed_mcp_configure_required', |
| 1465 |
sprintf( |
| 1466 |
/* translators: 1: comma-separated field names, 2: module slug. */ |
| 1467 |
__( 'Writing credential fields (%1$s) on "%2$s" needs the "configure" scope, which is off by default. Reconnect the MCP client granting the configure scope, or set these credentials from the xSpeed dashboard.', 'xspeed' ), |
| 1468 |
implode( ', ', $secret_fields ), |
| 1469 |
$module |
| 1470 |
), |
| 1471 |
array( |
| 1472 |
'status' => 403, |
| 1473 |
'refused_fields' => $secret_fields, |
| 1474 |
) |
| 1475 |
); |
| 1476 |
} |
| 1477 |
} |
| 1478 |
// The Pro licence WRITE gate. `settings_module_reachable()` above already |
| 1479 |
// hides locked Pro modules, but that is a VISIBILITY check answered by |
| 1480 |
// the `xspeed_module_descriptor` filter — a different question from "may |
| 1481 |
// this be written", and one that drifts the moment Pro changes how it |
| 1482 |
// flags `locked`. Ask the write gate itself, the same one REST consults |
| 1483 |
// via Module::update_settings(), so the two can't disagree. |
| 1484 |
// |
| 1485 |
// This is not theoretical: with the descriptor's `locked` flag removed, |
| 1486 |
// this handler wrote `enabled: false -> true` to a module whose |
| 1487 |
// is_license_locked() was true, because it persists through |
| 1488 |
// Settings_Manager::update() and never reaches Module::update_settings(). |
| 1489 |
// (#185) |
| 1490 |
$module_object = \XSpeed\Module_Registry::get( $module ); |
| 1491 |
if ( $module_object && $module_object->is_license_locked() ) { |
| 1492 |
// Match the REST path's audit trail — a refused write is a security |
| 1493 |
// event and must be visible in the activity log wherever it came |
| 1494 |
// from. Module::license_write_refusal() records the same type. |
| 1495 |
\XSpeed\Activity_Log::record( |
| 1496 |
'license_write_refused', |
| 1497 |
sprintf( |
| 1498 |
/* translators: %s: module slug. */ |
| 1499 |
__( 'Refused an MCP settings write to the Pro module "%s" — no valid license.', 'xspeed' ), |
| 1500 |
$module |
| 1501 |
), |
| 1502 |
\XSpeed\Activity_Log::WARN |
| 1503 |
); |
| 1504 |
|
| 1505 |
return new \WP_Error( |
| 1506 |
'xspeed_license_required', |
| 1507 |
sprintf( |
| 1508 |
/* translators: %s: module slug. */ |
| 1509 |
__( '"%s" is a Pro module and this site has no active license, so the write was refused. Nothing was changed.', 'xspeed' ), |
| 1510 |
$module |
| 1511 |
), |
| 1512 |
array( |
| 1513 |
'status' => 403, |
| 1514 |
'module' => $module, |
| 1515 |
) |
| 1516 |
); |
| 1517 |
} |
| 1518 |
|
| 1519 |
// An agent cannot tell a silent no-op from a real write, so refuse |
| 1520 |
// instead of returning a success payload. update() walks the schema: |
| 1521 |
// an out-of-schema key is never written and never mentioned, and an |
| 1522 |
// in-schema key with a rejected value quietly keeps the stored one. |
| 1523 |
// The realistic case is `cache_enabled` on the `cache` module — the |
| 1524 |
// most natural way to ask for caching, and a complete no-op. (#206) |
| 1525 |
$report = self::inspect_or_error( $module, $values ); |
| 1526 |
if ( is_wp_error( $report ) ) { |
| 1527 |
return $report; |
| 1528 |
} |
| 1529 |
|
| 1530 |
/** |
| 1531 |
* Filter the update_settings MCP payload for one module. |
| 1532 |
* |
| 1533 |
* The write path's twin of the get filter above — this is where a |
| 1534 |
* module can say "stored, but inert on this host" in the same |
| 1535 |
* response that reports the write, instead of returning a plain |
| 1536 |
* success an agent relays as "enabled". Documented in |
| 1537 |
* docs/guides/hooks-and-filters.md. |
| 1538 |
* |
| 1539 |
* @param array<string,mixed> $payload The response: module + settings. |
| 1540 |
* @param string $module Module slug. |
| 1541 |
* @param string $action 'update' here; 'get' on reads. |
| 1542 |
*/ |
| 1543 |
return apply_filters( |
| 1544 |
'xspeed_mcp_settings_payload', |
| 1545 |
array( |
| 1546 |
'module' => $module, |
| 1547 |
// Return value is already masked (Settings_Manager::update returns the |
| 1548 |
// public view), so a written secret isn't echoed back either. (#115) |
| 1549 |
'settings' => Settings_Manager::update( $module, $values ), |
| 1550 |
), |
| 1551 |
$module, |
| 1552 |
'update' |
| 1553 |
); |
| 1554 |
} |
| 1555 |
|
| 1556 |
/** |
| 1557 |
* Refuse a settings payload carrying keys that would be silently dropped. |
| 1558 |
* |
| 1559 |
* @param string $module Module slug. |
| 1560 |
* @param array<string,mixed> $values Proposed values. |
| 1561 |
* @return true|\WP_Error True when every key would be applied. |
| 1562 |
*/ |
| 1563 |
private static function inspect_or_error( string $module, array $values ) { |
| 1564 |
$report = Settings_Manager::inspect_input( $module, $values ); |
| 1565 |
if ( empty( $report['unknown'] ) && empty( $report['invalid'] ) && empty( $report['locked'] ) ) { |
| 1566 |
return true; |
| 1567 |
} |
| 1568 |
|
| 1569 |
$parts = array(); |
| 1570 |
// A field pinned by a wp-config.php constant cannot be written. Say so |
| 1571 |
// rather than returning a success the agent relays as "changed" over a |
| 1572 |
// write that update() would silently drop. (#398) |
| 1573 |
foreach ( $report['locked'] as $key ) { |
| 1574 |
$spec = ( Module_Registry::get( $module ) ? Module_Registry::get( $module )->settings_schema()[ $key ] ?? array() : array() ); |
| 1575 |
$constant = Settings_Manager::effective_constant( $module, $key, is_array( $spec ) ? $spec : array() ); |
| 1576 |
$parts[] = sprintf( |
| 1577 |
/* translators: 1: setting key, 2: wp-config.php constant name. */ |
| 1578 |
__( '"%1$s" is defined in wp-config.php as %2$s and cannot be changed here', 'xspeed' ), |
| 1579 |
$key, |
| 1580 |
(string) $constant |
| 1581 |
); |
| 1582 |
} |
| 1583 |
foreach ( $report['unknown'] as $key ) { |
| 1584 |
$detail = sprintf( |
| 1585 |
/* translators: 1: setting key, 2: module slug. */ |
| 1586 |
__( '"%1$s" is not a setting of module "%2$s"', 'xspeed' ), |
| 1587 |
$key, |
| 1588 |
$module |
| 1589 |
); |
| 1590 |
$hint = Settings_Manager::hint_for_unknown_key( $key ); |
| 1591 |
if ( '' !== $hint ) { |
| 1592 |
$detail .= ' — ' . $hint; |
| 1593 |
} else { |
| 1594 |
$near = Settings_Manager::did_you_mean( $module, $key ); |
| 1595 |
if ( ! empty( $near ) ) { |
| 1596 |
$detail .= sprintf( |
| 1597 |
/* translators: %s: comma-separated setting names. */ |
| 1598 |
__( ' — did you mean: %s?', 'xspeed' ), |
| 1599 |
implode( ', ', $near ) |
| 1600 |
); |
| 1601 |
} |
| 1602 |
} |
| 1603 |
$parts[] = $detail; |
| 1604 |
} |
| 1605 |
foreach ( $report['invalid'] as $key ) { |
| 1606 |
$parts[] = sprintf( |
| 1607 |
/* translators: %s: setting key. */ |
| 1608 |
__( '"%s" was rejected by the schema (wrong type, or outside the allowed range/options)', 'xspeed' ), |
| 1609 |
$key |
| 1610 |
); |
| 1611 |
} |
| 1612 |
|
| 1613 |
return new \WP_Error( |
| 1614 |
'xspeed_settings_refused', |
| 1615 |
sprintf( |
| 1616 |
/* translators: 1: module slug, 2: reasons. */ |
| 1617 |
__( 'Refused to update %1$s — nothing was written. %2$s', 'xspeed' ), |
| 1618 |
$module, |
| 1619 |
implode( '; ', $parts ) |
| 1620 |
), |
| 1621 |
array( |
| 1622 |
'status' => 400, |
| 1623 |
'refused_unknown' => $report['unknown'], |
| 1624 |
'refused_invalid' => $report['invalid'], |
| 1625 |
'refused_locked' => $report['locked'], |
| 1626 |
'would_apply' => $report['applied'], |
| 1627 |
) |
| 1628 |
); |
| 1629 |
} |
| 1630 |
|
| 1631 |
/** |
| 1632 |
* List every command run_command can invoke (the full CLI surface). |
| 1633 |
* |
| 1634 |
* @param array $args Unused. |
| 1635 |
* @return array |
| 1636 |
*/ |
| 1637 |
public static function list_commands( array $args ) { |
| 1638 |
unset( $args ); |
| 1639 |
return array( 'commands' => Cli_Bridge::catalog() ); |
| 1640 |
} |
| 1641 |
|
| 1642 |
/** |
| 1643 |
* Run any registered xSpeed command via the CLI bridge. |
| 1644 |
* |
| 1645 |
* @param array $args { command:string, args?:array, options?:array }. |
| 1646 |
* @return array|\WP_Error |
| 1647 |
*/ |
| 1648 |
public static function run_command( array $args ) { |
| 1649 |
$command = isset( $args['command'] ) ? (string) $args['command'] : ''; |
| 1650 |
if ( '' === $command ) { |
| 1651 |
return new \WP_Error( |
| 1652 |
'xspeed_mcp_missing_command', |
| 1653 |
__( 'The "command" parameter is required.', 'xspeed' ), |
| 1654 |
array( 'status' => 400 ) |
| 1655 |
); |
| 1656 |
} |
| 1657 |
$positional = isset( $args['args'] ) && is_array( $args['args'] ) ? $args['args'] : array(); |
| 1658 |
$options = isset( $args['options'] ) && is_array( $args['options'] ) ? $args['options'] : array(); |
| 1659 |
return Cli_Bridge::run( $command, $positional, $options ); |
| 1660 |
} |
| 1661 |
|
| 1662 |
/* --------------------------------------------------------------------- */ |
| 1663 |
/* Promoted action handlers — typed wrappers over Cli_Bridge. */ |
| 1664 |
/* Delegating to the bridge lets a Free tool drive a Pro action (psi, */ |
| 1665 |
/* ccss) with no cross-repo class reference, and keeps zero drift. */ |
| 1666 |
/* --------------------------------------------------------------------- */ |
| 1667 |
|
| 1668 |
/** |
| 1669 |
* Purge the Cloudflare edge cache. |
| 1670 |
* |
| 1671 |
* @param array $args Unused. |
| 1672 |
* @return array|\WP_Error |
| 1673 |
*/ |
| 1674 |
public static function purge_cloudflare( array $args ) { |
| 1675 |
unset( $args ); |
| 1676 |
return Cli_Bridge::run( 'cf', array( 'purge' ) ); |
| 1677 |
} |
| 1678 |
|
| 1679 |
/** |
| 1680 |
* Scan the database for bloat (no deletion). |
| 1681 |
* |
| 1682 |
* @param array $args Unused. |
| 1683 |
* @return array|\WP_Error |
| 1684 |
*/ |
| 1685 |
public static function scan_database( array $args ) { |
| 1686 |
unset( $args ); |
| 1687 |
$result = Cli_Bridge::run( 'db', array( 'scan' ) ); |
| 1688 |
if ( is_wp_error( $result ) || empty( $result['ok'] ) ) { |
| 1689 |
return $result; |
| 1690 |
} |
| 1691 |
|
| 1692 |
/* |
| 1693 |
* Mint the token clean_database will demand, and state what it covers. |
| 1694 |
* |
| 1695 |
* The scan is the only place the caller can see what is about to be |
| 1696 |
* destroyed, so it is the only honest place to authorise the delete. |
| 1697 |
* The token is bound to the CATEGORIES ENABLED and the COUNTS FOUND at |
| 1698 |
* this moment: if either moves before the delete lands, the token no |
| 1699 |
* longer describes reality and clean_database refuses. That closes the |
| 1700 |
* window where a scan is shown to a human, something changes, and the |
| 1701 |
* delete removes more than was agreed to. (#184) |
| 1702 |
*/ |
| 1703 |
$result['confirm_token'] = self::mint_clean_token(); |
| 1704 |
$result['confirm_note'] = __( 'This preview deletes nothing. To delete what is listed, call clean_database with this confirm_token. It expires in 5 minutes and stops working if the database changes.', 'xspeed' ); |
| 1705 |
|
| 1706 |
return $result; |
| 1707 |
} |
| 1708 |
|
| 1709 |
/** Categories currently enabled for deletion, with what a scan found in each. */ |
| 1710 |
private static function clean_scope(): array { |
| 1711 |
$enabled = array_keys( array_filter( Settings_Manager::get( 'database' ), static fn( $v ) => true === $v ) ); |
| 1712 |
sort( $enabled ); |
| 1713 |
|
| 1714 |
$counts = array(); |
| 1715 |
foreach ( Database_Cleaner::scan() as $key => $row ) { |
| 1716 |
$counts[ $key ] = is_array( $row ) ? (int) ( $row['count'] ?? 0 ) : (int) $row; |
| 1717 |
} |
| 1718 |
ksort( $counts ); |
| 1719 |
|
| 1720 |
return array( |
| 1721 |
'enabled' => $enabled, |
| 1722 |
'counts' => $counts, |
| 1723 |
); |
| 1724 |
} |
| 1725 |
|
| 1726 |
/** |
| 1727 |
* Actions that permanently destroy content and therefore require a |
| 1728 |
* confirm_token, keyed by canonical command name. |
| 1729 |
* |
| 1730 |
* Keyed by ACTION, not by tool name, because the same action is |
| 1731 |
* reachable through several tools (the typed clean_database, the |
| 1732 |
* run_command gateway, and any future wrapper). |
| 1733 |
* |
| 1734 |
* @return array<string, string[]> |
| 1735 |
*/ |
| 1736 |
private static function destructive_actions(): array { |
| 1737 |
/** |
| 1738 |
* Filter the command actions that require an explicit confirmation. |
| 1739 |
* |
| 1740 |
* @since 1.1.6 |
| 1741 |
* @param array<string, string[]> $actions Action names keyed by command. |
| 1742 |
*/ |
| 1743 |
return (array) apply_filters( |
| 1744 |
'xspeed_mcp_destructive_actions', |
| 1745 |
array( 'xspeed db' => array( 'clean' ) ) |
| 1746 |
); |
| 1747 |
} |
| 1748 |
|
| 1749 |
/** |
| 1750 |
* Name the destructive action a call would run, or '' if it is harmless. |
| 1751 |
* |
| 1752 |
* @param string $name Tool name. |
| 1753 |
* @param array $args Decoded tool arguments. |
| 1754 |
* @return string Canonical "<command> <action>", or '' when not destructive. |
| 1755 |
*/ |
| 1756 |
private static function destructive_action( string $name, array $args ): string { |
| 1757 |
// The gateway carries the real command in its arguments; a typed tool |
| 1758 |
// is identified by the command it is mapped to. |
| 1759 |
if ( 'run_command' === $name ) { |
| 1760 |
$command = isset( $args['command'] ) ? (string) $args['command'] : ''; |
| 1761 |
if ( '' === $command ) { |
| 1762 |
return ''; |
| 1763 |
} |
| 1764 |
$positional = isset( $args['args'] ) && is_array( $args['args'] ) ? $args['args'] : array(); |
| 1765 |
$resolved = Cli_Bridge::classify( $command, $positional ); |
| 1766 |
} elseif ( 'clean_database' === $name ) { |
| 1767 |
$resolved = Cli_Bridge::classify( 'db', array( 'clean' ) ); |
| 1768 |
} else { |
| 1769 |
return ''; |
| 1770 |
} |
| 1771 |
|
| 1772 |
if ( '' === $resolved['name'] ) { |
| 1773 |
return ''; |
| 1774 |
} |
| 1775 |
|
| 1776 |
$destructive = self::destructive_actions(); |
| 1777 |
if ( ! isset( $destructive[ $resolved['name'] ] ) ) { |
| 1778 |
return ''; |
| 1779 |
} |
| 1780 |
if ( ! in_array( $resolved['action'], (array) $destructive[ $resolved['name'] ], true ) ) { |
| 1781 |
return ''; |
| 1782 |
} |
| 1783 |
|
| 1784 |
return trim( $resolved['name'] . ' ' . $resolved['action'] ); |
| 1785 |
} |
| 1786 |
|
| 1787 |
/** |
| 1788 |
* Verify (and consume) the confirm_token minted by scan_database. |
| 1789 |
* |
| 1790 |
* @param array $args Decoded tool arguments. |
| 1791 |
* @return true|\WP_Error |
| 1792 |
*/ |
| 1793 |
private static function verify_clean_token( array $args ) { |
| 1794 |
$token = isset( $args['confirm_token'] ) ? (string) $args['confirm_token'] : ''; |
| 1795 |
if ( '' === $token ) { |
| 1796 |
return new \WP_Error( |
| 1797 |
'xspeed_mcp_confirm_required', |
| 1798 |
__( 'This permanently deletes content and cannot be undone. Call scan_database first to see exactly what would be removed, then pass the confirm_token it returns.', 'xspeed' ), |
| 1799 |
array( 'status' => 400 ) |
| 1800 |
); |
| 1801 |
} |
| 1802 |
|
| 1803 |
// Single use: consumed whether or not the delete goes ahead, so one |
| 1804 |
// approval can never authorise a second, different deletion. |
| 1805 |
$sealed = self::consume_clean_token( $token ); |
| 1806 |
if ( '' === $sealed ) { |
| 1807 |
return new \WP_Error( |
| 1808 |
'xspeed_mcp_confirm_invalid', |
| 1809 |
__( 'That confirm_token is unknown or has expired (they last 5 minutes). Run scan_database again and use the fresh token.', 'xspeed' ), |
| 1810 |
array( 'status' => 400 ) |
| 1811 |
); |
| 1812 |
} |
| 1813 |
|
| 1814 |
if ( ! hash_equals( $sealed, self::clean_fingerprint() ) ) { |
| 1815 |
return new \WP_Error( |
| 1816 |
'xspeed_mcp_confirm_stale', |
| 1817 |
__( 'The database changed since that scan, so the preview no longer describes what would be deleted. Run scan_database again and confirm against the new result.', 'xspeed' ), |
| 1818 |
array( 'status' => 409 ) |
| 1819 |
); |
| 1820 |
} |
| 1821 |
|
| 1822 |
return true; |
| 1823 |
} |
| 1824 |
|
| 1825 |
/** Fingerprint of the scope, so a token cannot outlive what it described. */ |
| 1826 |
private static function clean_fingerprint(): string { |
| 1827 |
return hash( 'sha256', (string) wp_json_encode( self::clean_scope() ) ); |
| 1828 |
} |
| 1829 |
|
| 1830 |
/** Lifetime of a confirm_token, from mint to refusal. */ |
| 1831 |
private const CLEAN_TOKEN_TTL = 5 * MINUTE_IN_SECONDS; |
| 1832 |
|
| 1833 |
/** Storage key for a minted token (the token itself is never stored). */ |
| 1834 |
private static function clean_token_key( string $token ): string { |
| 1835 |
return 'xspeed_mcp_clean_' . hash( 'sha256', $token ); |
| 1836 |
} |
| 1837 |
|
| 1838 |
/* |
| 1839 |
* The token is held in an OPTION, not a transient. |
| 1840 |
* |
| 1841 |
* scan_database and clean_database are two separate HTTP requests, so the |
| 1842 |
* token has to survive between them. With an external object cache |
| 1843 |
* installed, set_transient() writes to that cache ONLY and never touches |
| 1844 |
* the options table — so on any site whose object cache is |
| 1845 |
* non-persistent, flushed between requests, or simply orphaned (a stale |
| 1846 |
* W3TC/Redis drop-in pointing at a dead backend), the token evaporates the |
| 1847 |
* moment it is minted. |
| 1848 |
* |
| 1849 |
* That does not fail safe. It makes the confirmation UNSATISFIABLE: |
| 1850 |
* clean_database can never be authorised by any sequence of calls, and the |
| 1851 |
* operator's only remaining route to the feature is the admin panel. A |
| 1852 |
* guard that cannot be passed is a broken feature, and the pressure it |
| 1853 |
* creates is to remove the guard. Reproduced on a stack running W3 Total |
| 1854 |
* Cache's object-cache drop-in: every freshly minted token was refused as |
| 1855 |
* "unknown or expired" on the very next request. (#184) |
| 1856 |
* |
| 1857 |
* Options are backed by the database, so the token persists whatever the |
| 1858 |
* object cache does. Expiry is carried in the stored value and checked on |
| 1859 |
* read, since options have no TTL of their own. |
| 1860 |
*/ |
| 1861 |
|
| 1862 |
private static function mint_clean_token(): string { |
| 1863 |
$token = wp_generate_password( 32, false ); |
| 1864 |
|
| 1865 |
// autoload=no: this is read once, by one request, minutes from now. |
| 1866 |
add_option( |
| 1867 |
self::clean_token_key( $token ), |
| 1868 |
wp_json_encode( |
| 1869 |
array( |
| 1870 |
'fingerprint' => self::clean_fingerprint(), |
| 1871 |
'expires' => time() + self::CLEAN_TOKEN_TTL, |
| 1872 |
) |
| 1873 |
), |
| 1874 |
'', |
| 1875 |
'no' |
| 1876 |
); |
| 1877 |
|
| 1878 |
self::purge_expired_clean_tokens(); |
| 1879 |
|
| 1880 |
return $token; |
| 1881 |
} |
| 1882 |
|
| 1883 |
/** |
| 1884 |
* Read a minted token's sealed fingerprint, or '' if unknown/expired. |
| 1885 |
* |
| 1886 |
* Consumes the record either way: a token is single use, so one approval |
| 1887 |
* can never authorise a second, different deletion. |
| 1888 |
*/ |
| 1889 |
private static function consume_clean_token( string $token ): string { |
| 1890 |
$key = self::clean_token_key( $token ); |
| 1891 |
$stored = get_option( $key ); |
| 1892 |
if ( ! is_string( $stored ) || '' === $stored ) { |
| 1893 |
return ''; |
| 1894 |
} |
| 1895 |
|
| 1896 |
delete_option( $key ); |
| 1897 |
|
| 1898 |
$data = json_decode( $stored, true ); |
| 1899 |
if ( ! is_array( $data ) || empty( $data['fingerprint'] ) ) { |
| 1900 |
return ''; |
| 1901 |
} |
| 1902 |
if ( ! isset( $data['expires'] ) || time() > (int) $data['expires'] ) { |
| 1903 |
return ''; |
| 1904 |
} |
| 1905 |
|
| 1906 |
return (string) $data['fingerprint']; |
| 1907 |
} |
| 1908 |
|
| 1909 |
/** |
| 1910 |
* Drop token rows nobody consumed. |
| 1911 |
* |
| 1912 |
* Options have no TTL, so an unused token would otherwise sit in |
| 1913 |
* wp_options forever — a scan that is never followed by a clean is the |
| 1914 |
* normal case, not the exception. |
| 1915 |
*/ |
| 1916 |
private static function purge_expired_clean_tokens(): void { |
| 1917 |
global $wpdb; |
| 1918 |
|
| 1919 |
if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) { |
| 1920 |
return; |
| 1921 |
} |
| 1922 |
|
| 1923 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- no options API for "select by key prefix"; runs only when a token is minted. |
| 1924 |
$names = $wpdb->get_col( |
| 1925 |
$wpdb->prepare( |
| 1926 |
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", |
| 1927 |
$wpdb->esc_like( 'xspeed_mcp_clean_' ) . '%' |
| 1928 |
) |
| 1929 |
); |
| 1930 |
|
| 1931 |
foreach ( (array) $names as $name ) { |
| 1932 |
$data = json_decode( (string) get_option( $name ), true ); |
| 1933 |
if ( ! is_array( $data ) || ! isset( $data['expires'] ) || time() > (int) $data['expires'] ) { |
| 1934 |
delete_option( $name ); |
| 1935 |
} |
| 1936 |
} |
| 1937 |
} |
| 1938 |
|
| 1939 |
/** |
| 1940 |
* Clean database bloat (destructive). |
| 1941 |
* |
| 1942 |
* @param array $args Unused. |
| 1943 |
* @return array|\WP_Error |
| 1944 |
*/ |
| 1945 |
public static function clean_database( array $args ) { |
| 1946 |
/* |
| 1947 |
* The scan-before-clean confirmation is enforced in invoke(), which |
| 1948 |
* every tool passes through — see destructive_action(). It is NOT |
| 1949 |
* repeated here: the token is single-use, so checking it twice would |
| 1950 |
* consume it on the first check and reject the caller on the second. |
| 1951 |
* |
| 1952 |
* Reaching this line means the dispatcher already verified a token |
| 1953 |
* bound to a scan of the current database state. (#184) |
| 1954 |
*/ |
| 1955 |
unset( $args ); |
| 1956 |
return Cli_Bridge::run( 'db', array( 'clean' ) ); |
| 1957 |
} |
| 1958 |
|
| 1959 |
/** |
| 1960 |
* Flush the persistent object cache. |
| 1961 |
* |
| 1962 |
* @param array $args Unused. |
| 1963 |
* @return array|\WP_Error |
| 1964 |
*/ |
| 1965 |
public static function flush_object_cache( array $args ) { |
| 1966 |
unset( $args ); |
| 1967 |
return Cli_Bridge::run( 'objcache', array( 'flush' ) ); |
| 1968 |
} |
| 1969 |
|
| 1970 |
/** |
| 1971 |
* Start the cache preloader. |
| 1972 |
* |
| 1973 |
* @param array $args Unused. |
| 1974 |
* @return array|\WP_Error |
| 1975 |
*/ |
| 1976 |
public static function start_preloader( array $args ) { |
| 1977 |
unset( $args ); |
| 1978 |
return Cli_Bridge::run( 'preloader', array( 'start' ) ); |
| 1979 |
} |
| 1980 |
|
| 1981 |
/** |
| 1982 |
* Full health diagnostics (checks + stats + buckets + activity). |
| 1983 |
* Direct typed payload — same tier as get_cache_status — so the agent |
| 1984 |
* gets structured tones/ids instead of parsing CLI log lines. |
| 1985 |
* |
| 1986 |
* @param array $args Unused. |
| 1987 |
* @return array |
| 1988 |
*/ |
| 1989 |
public static function get_health( array $args ) { |
| 1990 |
unset( $args ); |
| 1991 |
return array( |
| 1992 |
'checks' => \XSpeed\Health::checks(), |
| 1993 |
'stats' => Cache::get_stats(), |
| 1994 |
'buckets' => \XSpeed\Hit_Counter::buckets(), |
| 1995 |
'hit_daily' => \XSpeed\Hit_Counter::daily_series( 30 ), |
| 1996 |
'activity' => \XSpeed\Activity_Log::entries(), |
| 1997 |
); |
| 1998 |
} |
| 1999 |
|
| 2000 |
/** |
| 2001 |
* Stored benchmark runs + settings-change events (trend data). |
| 2002 |
* |
| 2003 |
* @param array $args { limit?:int }. |
| 2004 |
* @return array |
| 2005 |
*/ |
| 2006 |
public static function get_benchmark_history( array $args ) { |
| 2007 |
$limit = isset( $args['limit'] ) ? max( 1, min( 100, (int) $args['limit'] ) ) : 100; |
| 2008 |
$changes = array(); |
| 2009 |
foreach ( \XSpeed\Activity_Log::entries() as $entry ) { |
| 2010 |
if ( 'settings_changed' === ( $entry['type'] ?? '' ) ) { |
| 2011 |
$changes[] = array( |
| 2012 |
'ts' => (int) $entry['ts'], |
| 2013 |
'message' => (string) $entry['message'], |
| 2014 |
); |
| 2015 |
} |
| 2016 |
} |
| 2017 |
return array( |
| 2018 |
'runs' => Cache_Benchmark::history( $limit ), |
| 2019 |
'changes' => $changes, |
| 2020 |
); |
| 2021 |
} |
| 2022 |
|
| 2023 |
/** |
| 2024 |
* Purge a single URL's cache entries. |
| 2025 |
* |
| 2026 |
* @param array $args { url:string }. |
| 2027 |
* @return array|\WP_Error |
| 2028 |
*/ |
| 2029 |
/** |
| 2030 |
* Inspect what is in the page cache (pages + age, or size breakdown). |
| 2031 |
* |
| 2032 |
* @param array $args detail: pages|size, limit. |
| 2033 |
* @return array|\WP_Error |
| 2034 |
*/ |
| 2035 |
public static function get_cache_inventory( array $args ) { |
| 2036 |
$detail = isset( $args['detail'] ) ? (string) $args['detail'] : 'pages'; |
| 2037 |
$action = 'size' === $detail ? 'size' : 'inventory'; |
| 2038 |
$assoc = array(); |
| 2039 |
if ( isset( $args['limit'] ) && '' !== $args['limit'] ) { |
| 2040 |
$assoc['limit'] = (string) $args['limit']; |
| 2041 |
} |
| 2042 |
return Cli_Bridge::run( 'cache', array( $action ), $assoc ); |
| 2043 |
} |
| 2044 |
|
| 2045 |
/** |
| 2046 |
* Recent cache purges and their causes. |
| 2047 |
* |
| 2048 |
* @param array $args limit. |
| 2049 |
* @return array|\WP_Error |
| 2050 |
*/ |
| 2051 |
public static function get_purge_log( array $args ) { |
| 2052 |
$assoc = array(); |
| 2053 |
if ( isset( $args['limit'] ) && '' !== $args['limit'] ) { |
| 2054 |
$assoc['limit'] = (string) $args['limit']; |
| 2055 |
} |
| 2056 |
return Cli_Bridge::run( 'cache', array( 'purge-log' ), $assoc ); |
| 2057 |
} |
| 2058 |
|
| 2059 |
/** |
| 2060 |
* Re-verify (and repair) the server rewrite rules. |
| 2061 |
* |
| 2062 |
* @param array $args Unused. |
| 2063 |
* @return array|\WP_Error |
| 2064 |
*/ |
| 2065 |
public static function recheck_rewrite_rules( array $args ) { |
| 2066 |
unset( $args ); |
| 2067 |
return Cli_Bridge::run( 'cache', array( 'recheck-rewrite' ) ); |
| 2068 |
} |
| 2069 |
|
| 2070 |
/** |
| 2071 |
* Turn Cloudflare development mode on or off. |
| 2072 |
* |
| 2073 |
* A boolean rather than two tools: dev-on and dev-off are one decision, |
| 2074 |
* and offering them separately doubles the surface for no gain. |
| 2075 |
* |
| 2076 |
* @param array $args enabled (bool, required). |
| 2077 |
* @return array|\WP_Error |
| 2078 |
*/ |
| 2079 |
public static function set_cloudflare_dev_mode( array $args ) { |
| 2080 |
if ( ! array_key_exists( 'enabled', $args ) ) { |
| 2081 |
return new \WP_Error( 'xspeed_mcp_missing_enabled', __( 'The enabled argument is required.', 'xspeed' ), array( 'status' => 400 ) ); |
| 2082 |
} |
| 2083 |
$on = filter_var( $args['enabled'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); |
| 2084 |
if ( null === $on ) { |
| 2085 |
return new \WP_Error( 'xspeed_mcp_invalid_enabled', __( 'The enabled argument must be true or false.', 'xspeed' ), array( 'status' => 400 ) ); |
| 2086 |
} |
| 2087 |
return Cli_Bridge::run( 'cf', array( $on ? 'dev-on' : 'dev-off' ) ); |
| 2088 |
} |
| 2089 |
|
| 2090 |
/** |
| 2091 |
* Optimize database tables (distinct from clean_database, which deletes). |
| 2092 |
* |
| 2093 |
* @param array $args Unused. |
| 2094 |
* @return array|\WP_Error |
| 2095 |
*/ |
| 2096 |
public static function optimize_database( array $args ) { |
| 2097 |
unset( $args ); |
| 2098 |
return Cli_Bridge::run( 'db', array( 'optimize' ) ); |
| 2099 |
} |
| 2100 |
|
| 2101 |
/** |
| 2102 |
* Object cache state, or the server snippet that enables it. |
| 2103 |
* |
| 2104 |
* @param array $args detail: status|snippet. |
| 2105 |
* @return array|\WP_Error |
| 2106 |
*/ |
| 2107 |
public static function get_object_cache_status( array $args ) { |
| 2108 |
$detail = isset( $args['detail'] ) ? (string) $args['detail'] : 'status'; |
| 2109 |
$action = 'snippet' === $detail ? 'snippet' : 'status'; |
| 2110 |
return Cli_Bridge::run( 'objcache', array( $action ) ); |
| 2111 |
} |
| 2112 |
|
| 2113 |
/** |
| 2114 |
* Install or remove the object-cache drop-in. |
| 2115 |
* |
| 2116 |
* @param array $args enabled (bool, required). |
| 2117 |
* @return array|\WP_Error |
| 2118 |
*/ |
| 2119 |
public static function toggle_object_cache( array $args ) { |
| 2120 |
if ( ! array_key_exists( 'enabled', $args ) ) { |
| 2121 |
return new \WP_Error( 'xspeed_mcp_missing_enabled', __( 'The enabled argument is required.', 'xspeed' ), array( 'status' => 400 ) ); |
| 2122 |
} |
| 2123 |
$on = filter_var( $args['enabled'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); |
| 2124 |
if ( null === $on ) { |
| 2125 |
return new \WP_Error( 'xspeed_mcp_invalid_enabled', __( 'The enabled argument must be true or false.', 'xspeed' ), array( 'status' => 400 ) ); |
| 2126 |
} |
| 2127 |
return Cli_Bridge::run( 'objcache', array( $on ? 'enable' : 'disable' ) ); |
| 2128 |
} |
| 2129 |
|
| 2130 |
/** |
| 2131 |
* List or clear stored Critical CSS. |
| 2132 |
* |
| 2133 |
* @param array $args action: list|clear. |
| 2134 |
* @return array|\WP_Error |
| 2135 |
*/ |
| 2136 |
public static function manage_critical_css( array $args ) { |
| 2137 |
$action = isset( $args['action'] ) ? (string) $args['action'] : ''; |
| 2138 |
if ( ! in_array( $action, array( 'list', 'clear' ), true ) ) { |
| 2139 |
return new \WP_Error( 'xspeed_mcp_invalid_action', __( 'The action argument must be "list" or "clear".', 'xspeed' ), array( 'status' => 400 ) ); |
| 2140 |
} |
| 2141 |
return Cli_Bridge::run( 'ccss', array( $action ) ); |
| 2142 |
} |
| 2143 |
|
| 2144 |
/** |
| 2145 |
* Preloader progress. |
| 2146 |
* |
| 2147 |
* @param array $args Unused. |
| 2148 |
* @return array|\WP_Error |
| 2149 |
*/ |
| 2150 |
public static function get_preloader_status( array $args ) { |
| 2151 |
unset( $args ); |
| 2152 |
return Cli_Bridge::run( 'preloader', array( 'status' ) ); |
| 2153 |
} |
| 2154 |
|
| 2155 |
/** |
| 2156 |
* Stop a running preload. |
| 2157 |
* |
| 2158 |
* @param array $args Unused. |
| 2159 |
* @return array|\WP_Error |
| 2160 |
*/ |
| 2161 |
public static function stop_preloader( array $args ) { |
| 2162 |
unset( $args ); |
| 2163 |
return Cli_Bridge::run( 'preloader', array( 'stop' ) ); |
| 2164 |
} |
| 2165 |
|
| 2166 |
/** |
| 2167 |
* Stored external audit runs (PSI / GTmetrix). |
| 2168 |
* |
| 2169 |
* Read-only by construction: it reads the option Score already wrote. No |
| 2170 |
* outbound call is made, which is what lets the Hub poll this on a |
| 2171 |
* schedule without spending the site owner's PSI or GTmetrix quota. |
| 2172 |
* |
| 2173 |
* @param array $args limit. |
| 2174 |
* @return array|\WP_Error |
| 2175 |
*/ |
| 2176 |
public static function get_score_history( array $args ) { |
| 2177 |
if ( ! class_exists( '\\XSpeed\\Score' ) ) { |
| 2178 |
return new \WP_Error( 'xspeed_mcp_no_score', __( 'External scores are not available on this site.', 'xspeed' ), array( 'status' => 404 ) ); |
| 2179 |
} |
| 2180 |
|
| 2181 |
$limit = isset( $args['limit'] ) ? (int) $args['limit'] : 100; |
| 2182 |
$limit = max( 1, min( 500, $limit ) ); |
| 2183 |
|
| 2184 |
$history = \XSpeed\Score::history(); |
| 2185 |
|
| 2186 |
$runs = array(); |
| 2187 |
foreach ( array_slice( $history, 0, $limit ) as $run ) { |
| 2188 |
if ( ! is_array( $run ) ) { |
| 2189 |
continue; |
| 2190 |
} |
| 2191 |
$metrics = isset( $run['metrics'] ) && is_array( $run['metrics'] ) ? $run['metrics'] : array(); |
| 2192 |
$runs[] = array( |
| 2193 |
'provider' => isset( $run['provider'] ) ? (string) $run['provider'] : 'unknown', |
| 2194 |
'ts' => isset( $run['ts'] ) ? (int) $run['ts'] : 0, |
| 2195 |
'url' => isset( $run['url'] ) ? (string) $run['url'] : '', |
| 2196 |
'strategy' => isset( $run['strategy'] ) ? (string) $run['strategy'] : null, |
| 2197 |
// A failed audit and a successful one that returned no score |
| 2198 |
// both project to score null — ok is the only field that |
| 2199 |
// tells them apart, and error says why it failed. |
| 2200 |
'ok' => ! empty( $run['ok'] ), |
| 2201 |
'error' => isset( $run['error'] ) && '' !== $run['error'] ? (string) $run['error'] : null, |
| 2202 |
// Null, never 0: Score distinguishes "no score" from "scored |
| 2203 |
// zero", and flattening that reports a failed audit as a |
| 2204 |
// catastrophic result. |
| 2205 |
'score' => isset( $run['score'] ) && is_numeric( $run['score'] ) ? (int) $run['score'] : null, |
| 2206 |
'metrics' => array( |
| 2207 |
'lcp' => self::metric_or_null( $metrics, 'lcp' ), |
| 2208 |
'fcp' => self::metric_or_null( $metrics, 'fcp' ), |
| 2209 |
'cls' => self::metric_or_null( $metrics, 'cls' ), |
| 2210 |
'tbt' => self::metric_or_null( $metrics, 'tbt' ), |
| 2211 |
'si' => self::metric_or_null( $metrics, 'si' ), |
| 2212 |
'ttfb' => self::metric_or_null( $metrics, 'ttfb' ), |
| 2213 |
), |
| 2214 |
'report_url' => self::report_url_for( $run ), |
| 2215 |
); |
| 2216 |
} |
| 2217 |
|
| 2218 |
return array( |
| 2219 |
'runs' => $runs, |
| 2220 |
'total' => count( $history ), |
| 2221 |
); |
| 2222 |
} |
| 2223 |
|
| 2224 |
/** |
| 2225 |
* One metric as a float, or null when absent/non-numeric. |
| 2226 |
* |
| 2227 |
* @param array $metrics Metric bag. |
| 2228 |
* @param string $key Metric id. |
| 2229 |
*/ |
| 2230 |
private static function metric_or_null( array $metrics, string $key ): ?float { |
| 2231 |
return isset( $metrics[ $key ] ) && is_numeric( $metrics[ $key ] ) ? (float) $metrics[ $key ] : null; |
| 2232 |
} |
| 2233 |
|
| 2234 |
/** |
| 2235 |
* Deep link to the provider's own report, when one exists. |
| 2236 |
* |
| 2237 |
* GTmetrix hosts a durable report per test, so its id is enough to build |
| 2238 |
* the link. PSI does NOT — a Lighthouse result is returned to the caller |
| 2239 |
* and never hosted, so there is genuinely nothing to link to and this |
| 2240 |
* returns null rather than inventing a URL that 404s. |
| 2241 |
* |
| 2242 |
* @param array $run One stored run. |
| 2243 |
*/ |
| 2244 |
private static function report_url_for( array $run ): ?string { |
| 2245 |
$provider = isset( $run['provider'] ) ? (string) $run['provider'] : ''; |
| 2246 |
if ( 'gtmetrix' !== $provider ) { |
| 2247 |
return null; |
| 2248 |
} |
| 2249 |
$test_id = isset( $run['test_id'] ) ? trim( (string) $run['test_id'] ) : ''; |
| 2250 |
if ( '' === $test_id ) { |
| 2251 |
return null; |
| 2252 |
} |
| 2253 |
return 'https://gtmetrix.com/reports/' . rawurlencode( $test_id ); |
| 2254 |
} |
| 2255 |
|
| 2256 |
public static function purge_url( array $args ) { |
| 2257 |
$url = isset( $args['url'] ) ? trim( (string) $args['url'] ) : ''; |
| 2258 |
if ( '' === $url ) { |
| 2259 |
return new \WP_Error( 'xspeed_mcp_missing_url', __( 'The url argument is required.', 'xspeed' ), array( 'status' => 400 ) ); |
| 2260 |
} |
| 2261 |
return Cli_Bridge::run( 'cache', array( 'purge-url', $url ), array( 'cause' => __( 'AI assistant', 'xspeed' ) ) ); |
| 2262 |
} |
| 2263 |
|
| 2264 |
/** |
| 2265 |
* Probe the configured object-cache backend (connect + read/write). |
| 2266 |
* |
| 2267 |
* @param array $args Unused. |
| 2268 |
* @return array|\WP_Error |
| 2269 |
*/ |
| 2270 |
public static function test_object_cache( array $args ) { |
| 2271 |
unset( $args ); |
| 2272 |
return Cli_Bridge::run( 'objcache', array( 'test' ) ); |
| 2273 |
} |
| 2274 |
|
| 2275 |
/** |
| 2276 |
* Verify the saved Cloudflare credentials. |
| 2277 |
* |
| 2278 |
* @param array $args Unused. |
| 2279 |
* @return array|\WP_Error |
| 2280 |
*/ |
| 2281 |
public static function cloudflare_verify( array $args ) { |
| 2282 |
unset( $args ); |
| 2283 |
return Cli_Bridge::run( 'cf', array( 'verify' ) ); |
| 2284 |
} |
| 2285 |
|
| 2286 |
/** |
| 2287 |
* Run an external audit on any install. |
| 2288 |
* |
| 2289 |
* Shares run_pagespeed's body: that handler ALREADY falls back to |
| 2290 |
* `xspeed score run` when the Pro `xspeed psi` command is absent, so the |
| 2291 |
* engine could always do this on Free — the tool was simply dropped from |
| 2292 |
* the catalog before anyone could call it. The only thing missing was a |
| 2293 |
* name that survives on a Free install. (#147) |
| 2294 |
* |
| 2295 |
* @param array $args target / strategy / provider. |
| 2296 |
* @return array|\WP_Error |
| 2297 |
*/ |
| 2298 |
public static function run_score( array $args ) { |
| 2299 |
// `target` is the CLI's name for it (--url is a reserved WP-CLI global, |
| 2300 |
// so the score command deliberately uses --target). Accept both here |
| 2301 |
// and normalise, so an assistant that guessed `url` still works. |
| 2302 |
if ( ! empty( $args['target'] ) && empty( $args['url'] ) ) { |
| 2303 |
$args['url'] = (string) $args['target']; |
| 2304 |
} |
| 2305 |
return self::run_pagespeed( $args ); |
| 2306 |
} |
| 2307 |
|
| 2308 |
/** |
| 2309 |
* Run an external performance audit. Prefers the Pro engine when present, |
| 2310 |
* otherwise drives Free's own score command. |
| 2311 |
* |
| 2312 |
* @param array $args { url?:string, strategy?:string, provider?:string, force?:bool }. |
| 2313 |
* @return array|\WP_Error |
| 2314 |
*/ |
| 2315 |
public static function run_pagespeed( array $args ) { |
| 2316 |
$options = array(); |
| 2317 |
if ( ! empty( $args['url'] ) ) { |
| 2318 |
$options['url'] = (string) $args['url']; |
| 2319 |
} |
| 2320 |
if ( ! empty( $args['strategy'] ) ) { |
| 2321 |
$options['strategy'] = (string) $args['strategy']; |
| 2322 |
} |
| 2323 |
// Advertised in run_score's schema, and the Free score handler already |
| 2324 |
// branches on it (ScoreModule::cli_handler reads $assoc['provider']), |
| 2325 |
// so dropping it here meant a GTmetrix request ran a PSI audit and |
| 2326 |
// reported ok:true — spending the wrong provider's quota with nothing |
| 2327 |
// in the response to say so. (QA B1 on #162) |
| 2328 |
if ( ! empty( $args['provider'] ) ) { |
| 2329 |
$options['provider'] = (string) $args['provider']; |
| 2330 |
} |
| 2331 |
// Was reachable only via the generated xspeed_psi alias, which this |
| 2332 |
// change removes — so it moves onto the typed tool rather than being |
| 2333 |
// lost with it. |
| 2334 |
if ( ! empty( $args['force'] ) && filter_var( $args['force'], FILTER_VALIDATE_BOOLEAN ) ) { |
| 2335 |
$options['force'] = true; |
| 2336 |
} |
| 2337 |
|
| 2338 |
/* |
| 2339 |
* Prefer the richer Pro engine when it's installed; otherwise drive |
| 2340 |
* Free's own score command. Same tool name either way — an assistant |
| 2341 |
* asking for a PageSpeed audit shouldn't have to know which tier the |
| 2342 |
* site runs, and the two write to the same run history. |
| 2343 |
* |
| 2344 |
* EXCEPT when a provider was named that the Pro engine cannot serve. |
| 2345 |
* `xspeed psi` is PageSpeed-only: it declares no --provider and |
| 2346 |
* discards the option, so preferring it purely because it exists made |
| 2347 |
* `provider: "gtmetrix"` run PSI and answer ok:true — the same silent |
| 2348 |
* wrong-provider bug this tool just fixed on Free, reappearing only on |
| 2349 |
* Pro. A site that configures GTmetrix would have stopped getting it |
| 2350 |
* the moment Pro activated. Free's `score` command reads $assoc |
| 2351 |
* ['provider'] and branches, so route there instead. (QA R1 on #162) |
| 2352 |
*/ |
| 2353 |
$wants_non_psi = isset( $options['provider'] ) && 'psi' !== strtolower( (string) $options['provider'] ); |
| 2354 |
if ( isset( Cli_Bridge::commands()['xspeed psi'] ) && ! $wants_non_psi ) { |
| 2355 |
return Cli_Bridge::run( 'psi', array(), $options ); |
| 2356 |
} |
| 2357 |
|
| 2358 |
// The Free `score` command reads --target, not --url: `url` is a |
| 2359 |
// reserved WP-CLI global, so a value passed as `url` never reaches the |
| 2360 |
// handler and the requested page is silently ignored in favour of the |
| 2361 |
// default. Translate rather than passing it through. (#147) |
| 2362 |
if ( isset( $options['url'] ) ) { |
| 2363 |
$options['target'] = $options['url']; |
| 2364 |
unset( $options['url'] ); |
| 2365 |
} |
| 2366 |
return Cli_Bridge::run( 'score', array( 'run' ), $options ); |
| 2367 |
} |
| 2368 |
|
| 2369 |
/** |
| 2370 |
* Generate Critical CSS (Pro). |
| 2371 |
* |
| 2372 |
* @param array $args Unused. |
| 2373 |
* @return array|\WP_Error |
| 2374 |
*/ |
| 2375 |
public static function generate_critical_css( array $args ) { |
| 2376 |
unset( $args ); |
| 2377 |
return Cli_Bridge::run( 'ccss', array( 'generate' ) ); |
| 2378 |
} |
| 2379 |
|
| 2380 |
/** |
| 2381 |
* Build a JSON Schema object node. |
| 2382 |
* |
| 2383 |
* @param array $properties Property map. |
| 2384 |
* @param string[] $required Required property names. |
| 2385 |
*/ |
| 2386 |
private static function object_schema( array $properties, array $required ): array { |
| 2387 |
$schema = array( |
| 2388 |
'type' => 'object', |
| 2389 |
'properties' => (object) $properties, |
| 2390 |
); |
| 2391 |
if ( ! empty( $required ) ) { |
| 2392 |
$schema['required'] = array_values( $required ); |
| 2393 |
} |
| 2394 |
return $schema; |
| 2395 |
} |
| 2396 |
} |
| 2397 |
|