| 1 |
<?php |
| 2 |
/** |
| 3 |
* Run WP-CLI Command Ability. |
| 4 |
* |
| 5 |
* Dispatch strategy: |
| 6 |
* 1. The code runs inside a WP-CLI process (`defined( 'WP_CLI' )`). |
| 7 |
* It delegates to `\WP_CLI::runcommand()`. This gives full WP-CLI |
| 8 |
* support in-process. |
| 9 |
* 2. The code runs in a web, REST or AJAX context. |
| 10 |
* It dispatches the command to a native WordPress PHP function. |
| 11 |
* The allowlist below selects the function. It uses no subprocess, |
| 12 |
* no shell, no `proc_open` and no `eval`. |
| 13 |
* |
| 14 |
* The native dispatcher covers the WP-CLI commands an AI agent uses to |
| 15 |
* manage a site (plugin, theme, option, post, user, menu, cron, cache, |
| 16 |
* transient, rewrite, core, language). A command outside the allowlist |
| 17 |
* returns a clear error. |
| 18 |
* |
| 19 |
* ## Authentication and authorisation chain |
| 20 |
* |
| 21 |
* A caller must pass every layer below to reach `execute()`: |
| 22 |
* |
| 23 |
* 1. The MCP REST endpoint `/zip-ai/v1/mcp` gate |
| 24 |
* (`REST_API::check_permission`). It accepts a valid Bearer token. |
| 25 |
* It matches the token with `hash_equals` against the decrypted |
| 26 |
* stored auth token. It also accepts a logged-in admin |
| 27 |
* (`current_user_can( 'manage_options' )`). |
| 28 |
* 2. `setup_user_context()` resolves `X-WP-User-ID` into a WP user on |
| 29 |
* the Bearer path. So `current_user_can()` inside the ability |
| 30 |
* shows the real capabilities of the acting user. |
| 31 |
* 3. The Abilities API permission_callback |
| 32 |
* (`Abstract_Ability::check_permission`) checks |
| 33 |
* `current_user_can( manage_options )` again. It runs this before it |
| 34 |
* routes to `handle_execute()`. |
| 35 |
* 4. This class's `execute()` adds more checks. It runs a second |
| 36 |
* `current_user_can()` check. It runs an explicit `is_super_admin()` |
| 37 |
* check on multisite. It runs `verify_command_security()` before |
| 38 |
* dispatch. |
| 39 |
* |
| 40 |
* `Abstract_Ability::handle_execute` records every successful call. It |
| 41 |
* writes to the `zip_ai_audit_log` option via `Event_Logger`. The record |
| 42 |
* holds the user id, the tool id, the sanitised input, the sanitised |
| 43 |
* output and a timestamp. |
| 44 |
* |
| 45 |
* Compliance: this file contains no forbidden functions from the |
| 46 |
* WordPress.org Plugin Guidelines. |
| 47 |
* |
| 48 |
* @since 0.0.1 |
| 49 |
* @package zip-ai |
| 50 |
*/ |
| 51 |
|
| 52 |
namespace ZipAI\MCP\Classes\Abilities\Core; |
| 53 |
|
| 54 |
defined( 'ABSPATH' ) || exit; |
| 55 |
|
| 56 |
use ZipAI\MCP\Classes\Abilities\Abstract_Ability; |
| 57 |
use ZipAI\MCP\Classes\Core\Tool_Types; |
| 58 |
use ZipAI\MCP\Classes\Core\Response; |
| 59 |
use ZipAI\MCP\Classes\Core\Utils; |
| 60 |
|
| 61 |
/** |
| 62 |
* Ability: execute a WP-CLI-style command via native WP functions. |
| 63 |
*/ |
| 64 |
class RunWpCli extends Abstract_Ability { |
| 65 |
|
| 66 |
use Security_Verifier_Trait; |
| 67 |
use Command_Parser_Trait; |
| 68 |
use Search_Replace_Engine_Trait; |
| 69 |
|
| 70 |
/** Max bytes of output to return. */ |
| 71 |
const MAX_OUTPUT_BYTES = 2097152; |
| 72 |
|
| 73 |
/** |
| 74 |
* Marks the tool as destructive for UI approval flows. |
| 75 |
* |
| 76 |
* @var bool |
| 77 |
*/ |
| 78 |
protected $is_destructive = true; |
| 79 |
|
| 80 |
/** |
| 81 |
* Configure the ability metadata. |
| 82 |
* |
| 83 |
* @return void |
| 84 |
*/ |
| 85 |
public function configure() { |
| 86 |
$this->id = 'zipai/run-wp-cli'; |
| 87 |
$this->label = 'Run WP-CLI Command'; |
| 88 |
$this->description = 'Run a WP-CLI-style command. Omit the leading "wp" — pass only the subcommand and flags. ' |
| 89 |
. 'When the plugin is running inside a WP-CLI process the full WP-CLI command surface is available; in web/REST context only the allowlisted commands below are dispatched to native WordPress functions.' |
| 90 |
. "\n\n" |
| 91 |
. 'READ COMMANDS:' |
| 92 |
. "\n" . ' plugin list [--status=active|inactive|all] [--format=json]' |
| 93 |
. "\n" . ' plugin get <slug> [--format=json]' |
| 94 |
. "\n" . ' plugin is-active <slug>' |
| 95 |
. "\n" . ' theme list [--status=active|inactive] [--format=json]' |
| 96 |
. "\n" . ' theme get [<slug>] [--format=json]' |
| 97 |
. "\n" . ' option get <key>' |
| 98 |
. "\n" . ' option list [--search=pattern]' |
| 99 |
. "\n" . ' post list [--post_type=…] [--post_status=…] [--per_page=…] [--page=…] [--fields=ID,post_title,…] [--format=json|count]' |
| 100 |
. "\n" . ' — paginated: returns `data.pagination` {total,total_pages,page,per_page,has_more}. Use --format=count for the total only; page through with --page when has_more is true.' |
| 101 |
. "\n" . ' post get <ID> [--fields=…] [--format=json]' |
| 102 |
. "\n" . ' post meta get <ID> <meta_key>' |
| 103 |
. "\n" . ' user list [--role=…] [--per_page=…] [--page=…] [--fields=ID,user_login,user_email,…] [--format=json|count]' |
| 104 |
. "\n" . ' user get <id-or-login> [--format=json]' |
| 105 |
. "\n" . ' user meta get <id> <meta_key>' |
| 106 |
. "\n" . ' menu list [--format=json]' |
| 107 |
. "\n" . ' menu item list <menu> [--format=json]' |
| 108 |
. "\n" . ' sidebar list [--format=json]' |
| 109 |
. "\n" . ' widget list <sidebar-id> [--format=json]' |
| 110 |
. "\n" . ' core version' |
| 111 |
. "\n" . ' core check-update — pending core updates (from WP\'s cached update data; no live wp.org call)' |
| 112 |
. "\n" . ' cli info — PHP / WordPress runtime facts for the request (no WP-CLI binary in web context)' |
| 113 |
. "\n" . ' language core list' |
| 114 |
. "\n" . ' cron event list [--format=json]' |
| 115 |
. "\n" . ' transient get <key>' |
| 116 |
. "\n" . ' transient list [--search=…] [--limit=…] [--network] — list transient keys + expiry (no values)' |
| 117 |
. "\n" . ' transient type — storage backend (database vs object-cache)' |
| 118 |
. "\n" . ' term list <taxonomy> [--per_page=…] [--page=…] [--hide_empty=true|false] [--format=json|count]' |
| 119 |
. "\n" . ' post meta list <ID> — all meta keys for a post' |
| 120 |
. "\n" . ' user meta list <ID> — all meta keys for a user' |
| 121 |
. "\n" . ' role list — registered user roles + cap counts' |
| 122 |
. "\n" . ' role list-caps <role> — capability set for one role' |
| 123 |
. "\n" . ' db size [--tables] [--human-readable] — database size summary' |
| 124 |
. "\n" . ' rewrite list — current permalink rewrite rules' |
| 125 |
. "\n" . ' cron schedule list — registered cron intervals' |
| 126 |
. "\n" . ' env — WP / PHP / MySQL / extensions diagnostic' |
| 127 |
. "\n" . ' option list [--search=…] [--autoload=on|off] [--limit=…] — list options, optionally filtered by autoload' |
| 128 |
. "\n" . ' post-type list [--public=true|false] [--show_in_rest=true|false] [--format=json]' |
| 129 |
. "\n" . ' taxonomy list [--public=true|false] [--object_type=<post_type>] [--format=json]' |
| 130 |
. "\n" . ' comment list [--status=approve|hold|spam|trash|all] [--post_id=<ID>] [--search=…] [--per_page=…] [--page=…] [--fields=…] [--format=json|count|ids]' |
| 131 |
. "\n" . ' — paginated like post list. Default --status=all covers approved + pending; spam/trash need an explicit --status.' |
| 132 |
. "\n" . ' comment get <ID> [--fields=…]' |
| 133 |
. "\n" . ' comment count [<post-ID>] — totals by status (approved, moderated, spam, trash)' |
| 134 |
. "\n" . ' comment status <ID>' |
| 135 |
. "\n" . ' comment exists <ID>' |
| 136 |
. "\n" . ' comment meta get <ID> <meta_key>' |
| 137 |
. "\n" . ' comment meta list <ID>' |
| 138 |
. "\n\n" |
| 139 |
. 'WRITE COMMANDS (require user approval):' |
| 140 |
. "\n" . ' theme activate <slug> — server-side switch_theme()' |
| 141 |
. "\n" . ' (plugin install/activate/deactivate/delete/update and theme install/delete/update' |
| 142 |
. "\n" . ' are NOT routed through `run-wp-cli` — use the dedicated' |
| 143 |
. "\n" . ' abilities zipai/install-plugin, zipai/activate-plugin,' |
| 144 |
. "\n" . ' zipai/deactivate-plugin, zipai/delete-plugin, zipai/update-plugin,' |
| 145 |
. "\n" . ' zipai/install-theme, zipai/delete-theme, zipai/update-theme.' |
| 146 |
. "\n" . ' Calling them via run-wp-cli returns an error.)' |
| 147 |
. "\n" . ' option update <key> <value> [--format=json]' |
| 148 |
. "\n" . ' — set a WP option. Idempotent (creates when missing).' |
| 149 |
. "\n" . ' Brick-risk keys (siteurl, home, admin_email, default_role,' |
| 150 |
. "\n" . ' db_version, blog_charset, users_can_register) refuse upfront.' |
| 151 |
. "\n" . ' option delete <key> — remove a WP option (idempotent on missing).' |
| 152 |
. "\n" . ' post create --post_type=page --post_status=publish --post_title="Title"' |
| 153 |
. "\n" . ' post update <ID> --post_title="New Title" (post_content edits are blocked here — use editor__apply_change with the page open in the block editor)' |
| 154 |
. "\n" . ' post delete <ID> [<ID2> …] [--force] — accepts multiple IDs in one call.' |
| 155 |
. "\n" . ' comment create --comment_post_ID=<ID> --comment_content="…" [--comment_author=…] [--comment_author_email=…] [--comment_approved=0|1]' |
| 156 |
. "\n" . ' comment update <ID> --comment_content="…" [--comment_author=…]' |
| 157 |
. "\n" . ' comment delete <ID> [<ID2> …] [--force] — trash by default; --force = permanent. Accepts multiple IDs.' |
| 158 |
. "\n" . ' comment approve|unapprove|spam|unspam|trash|untrash <ID> [<ID2> …] — moderation; accepts multiple IDs.' |
| 159 |
. "\n" . ' comment recount <post-ID> [<post-ID2> …] — recalculate cached comment_count on posts' |
| 160 |
. "\n" . ' menu create <name>' |
| 161 |
. "\n" . ' menu item add-post <menu-id> <post-id>' |
| 162 |
. "\n" . ' cache flush' |
| 163 |
. "\n" . ' rewrite flush' |
| 164 |
. "\n" . ' transient delete --expired — drop only expired transient pairs (safe cleanup)' |
| 165 |
. "\n" . ' transient delete --all — drop every transient (destructive cache flush)' |
| 166 |
. "\n" . ' user add-role <id-or-login> <role> — non-admin roles only (administrator/super-admin blocked)' |
| 167 |
. "\n" . ' user remove-role <id-or-login> <role> — same restriction' |
| 168 |
. "\n" . ' user set-role <id-or-login> <role> — replaces all roles; same restriction' |
| 169 |
. "\n" . ' user add-cap <id-or-login> <cap> — admin-class caps (manage_options, etc.) blocked' |
| 170 |
. "\n" . ' user remove-cap <id-or-login> <cap> — same restriction' |
| 171 |
. "\n\n" |
| 172 |
. 'SITE-WIDE OPERATIONS (require user approval — destructive):' |
| 173 |
. "\n" . ' search-replace "<old>" "<new>" [--all-tables] [--skip-columns=col1,col2] [--dry-run]' |
| 174 |
. "\n" . ' Serialized-PHP-aware text replacement across post_content, postmeta, options, comments, etc. ' |
| 175 |
. "\n" . ' `guid` and `user_pass` columns are ALWAYS skipped. Use --dry-run first to preview counts.' |
| 176 |
. "\n\n" |
| 177 |
. 'NOTES:' |
| 178 |
. "\n" . ' • In web/REST context, commands outside the allowlist return an explanatory error. Use the REST / Abilities API for uncovered operations.' |
| 179 |
. "\n" . ' • "post update --post_content=…" is intentionally blocked: it replaces the entire block-editor content with a plain string, destroying block markup. Use editor__apply_change with the page open in the block editor instead.' |
| 180 |
. "\n" . ' • Blocked for security: eval, eval-file, shell, package, server, db query, db import, db drop, option add/patch (use option update — it creates when missing), post/comment meta add/update/delete/patch, transient set/patch, transient delete <key> (specific-key — use --expired or --all instead), user create/update/delete, and user meta writes. Brick-risk option keys (siteurl, home, admin_email, default_role, db_version, blog_charset, users_can_register) refuse on option update/delete.'; |
| 181 |
$this->capability = 'manage_options'; |
| 182 |
} |
| 183 |
|
| 184 |
/** |
| 185 |
* Report the tool type to the MCP registry. |
| 186 |
* |
| 187 |
* @return string |
| 188 |
*/ |
| 189 |
public function get_tool_type() { |
| 190 |
return Tool_Types::ACTION; |
| 191 |
} |
| 192 |
|
| 193 |
/** |
| 194 |
* Return the tool's input schema. |
| 195 |
* |
| 196 |
* @return array<string,mixed> |
| 197 |
*/ |
| 198 |
public function get_input_schema() { |
| 199 |
return array( |
| 200 |
'type' => 'object', |
| 201 |
'properties' => array( |
| 202 |
'command' => array( |
| 203 |
'type' => 'string', |
| 204 |
'description' => 'WP-CLI subcommand and flags — without the leading "wp". ' |
| 205 |
. 'Examples: "option get siteurl", "plugin list --status=active --format=json", ' |
| 206 |
. '"post list --post_type=attachment --format=json --fields=ID,post_title,guid"', |
| 207 |
), |
| 208 |
), |
| 209 |
'required' => array( 'command' ), |
| 210 |
); |
| 211 |
} |
| 212 |
|
| 213 |
// ─── Entrypoint ───────────────────────────────────────────────────────────── |
| 214 |
|
| 215 |
/** |
| 216 |
* Execute the command. |
| 217 |
* |
| 218 |
* @param array<string,mixed> $input Validated input. |
| 219 |
* @return array<string,mixed> Response data. |
| 220 |
*/ |
| 221 |
public function execute( $input ) { |
| 222 |
// Defense-in-depth — four checks before dispatch. The Abilities API |
| 223 |
// permission_callback (`Abstract_Ability::check_permission`) already |
| 224 |
// gates this execute callback with `current_user_can($capability)` |
| 225 |
// before it is invoked; we re-run the check here so any future |
| 226 |
// direct caller is still subject to the same rule. |
| 227 |
if ( ! is_user_logged_in() ) { |
| 228 |
return Response::error( 'Authentication required.' ); |
| 229 |
} |
| 230 |
if ( ! current_user_can( $this->capability ) ) { |
| 231 |
return Response::error( 'Permission denied. You must have manage_options capability.' ); |
| 232 |
} |
| 233 |
if ( is_multisite() && ! is_super_admin() ) { |
| 234 |
return Response::error( 'Permission denied. You must be a network administrator on multisite installations to run WP-CLI commands.' ); |
| 235 |
} |
| 236 |
|
| 237 |
$command = trim( Utils::to_str( $input['command'] ?? '' ) ); |
| 238 |
if ( '' === $command ) { |
| 239 |
return Response::error( 'Empty command. Example: "option get siteurl"' ); |
| 240 |
} |
| 241 |
|
| 242 |
// Cap command length defensively — anything larger is not a real CLI |
| 243 |
// invocation and risks tying up the parser. |
| 244 |
if ( strlen( $command ) > 4096 ) { |
| 245 |
return Response::error( 'Command is too long. Break it into multiple invocations.' ); |
| 246 |
} |
| 247 |
|
| 248 |
// Strip accidental leading "wp ". |
| 249 |
$command = (string) preg_replace( '/^wp\s+/i', '', $command ); |
| 250 |
|
| 251 |
$args = $this->parse_command_to_args( $command ); |
| 252 |
if ( is_wp_error( $args ) ) { |
| 253 |
return Response::error( $args->get_error_message() ); |
| 254 |
} |
| 255 |
|
| 256 |
$security_check = $this->verify_command_security( $args ); |
| 257 |
if ( is_wp_error( $security_check ) ) { |
| 258 |
return Response::error( $security_check->get_error_message() ); |
| 259 |
} |
| 260 |
|
| 261 |
// Fast path: the WP-CLI runtime is ready. Delegate to it. But do NOT |
| 262 |
// delegate `search-replace`. |
| 263 |
// |
| 264 |
// The protections for `search-replace` live inside |
| 265 |
// `handle_search_replace()`. They exclude the users, usermeta, sitemeta |
| 266 |
// and ms-global tables. They also filter protected-option rows. The |
| 267 |
// verifier has no equal for these. It cannot know which needle matches |
| 268 |
// which option before the walk. A raw string passed to WP-CLI would run |
| 269 |
// an unguarded replacement (DSA-15). So this one family always uses the |
| 270 |
// native engine. |
| 271 |
// |
| 272 |
// The family test reads EVERY candidate split. It does not read only the |
| 273 |
// `parse_flags` split. Under one parser `--search search-replace <old> |
| 274 |
// <new>` binds the family name as a flag value. WP-CLI reads the same |
| 275 |
// name as the command. That would route the exact command this exception |
| 276 |
// keeps native. |
| 277 |
// |
| 278 |
// This exception does NOT claim full cover on both paths. |
| 279 |
// `verify_command_security()` runs for both executors. So every SECURITY |
| 280 |
// rule in it applies to both. But the plugin and theme lifecycle |
| 281 |
// redirects ("use the dedicated `zipai/install-plugin` ability", |
| 282 |
// `:386-415`) are dispatcher cases. They are not verifier rules. The |
| 283 |
// verifier lets those commands through whenever `wp_is_file_mod_allowed()` |
| 284 |
// is true. On this path they would install or delete server-side. That |
| 285 |
// skips the consent flow the dedicated abilities provide. |
| 286 |
// |
| 287 |
// This branch is NOT dead code. `wp mcp serve` reaches it. It uses the |
| 288 |
// bundled `lib/mcp-adapter`. Its `McpAdapter` inits on `init` under |
| 289 |
// WP-CLI. It is a stdio JSON-RPC MCP server. It routes `tools/call` to |
| 290 |
// abilities. This runs in a process where `WP_CLI` is defined. |
| 291 |
// |
| 292 |
// That entry point stops one hop short today. This is only because |
| 293 |
// `mcp-adapter/execute-ability` refuses anything without |
| 294 |
// `meta['mcp']['public'] === true`. No `zipai/*` ability sets it. One |
| 295 |
// `mcp_adapter_default_server_config` filter would make it reachable. So |
| 296 |
// treat this path as guarded by configuration. Do not treat it as |
| 297 |
// unreachable. |
| 298 |
// |
| 299 |
// A related point is wider than this ability. Only |
| 300 |
// `Rest_Api::handle_mcp_request` (`inc/api/rest-api.php:116`) enters the |
| 301 |
// MCP-context flag of `Protected_Options_Filter`. So on ANY non-REST MCP |
| 302 |
// transport the filter backstop is inert for every protected key. This is |
| 303 |
// tracked separately. |
| 304 |
if ( defined( 'WP_CLI' ) && WP_CLI && class_exists( '\WP_CLI' ) ) { |
| 305 |
$families = array_column( $this->candidate_positionals( $args ), 0 ); |
| 306 |
if ( ! in_array( 'search-replace', $families, true ) ) { |
| 307 |
return $this->run_via_wpcli_api( $command ); |
| 308 |
} |
| 309 |
} |
| 310 |
|
| 311 |
// Web / REST context — map to native WordPress PHP functions. |
| 312 |
return $this->dispatch_native( $args ); |
| 313 |
} |
| 314 |
|
| 315 |
// ─── Security ─────────────────────────────────────────────────────────────── |
| 316 |
// `verify_command_security()` lives in Security_Verifier_Trait |
| 317 |
// (security-verifier-trait.php). |
| 318 |
|
| 319 |
// ─── WP-CLI runtime passthrough ───────────────────────────────────────────── |
| 320 |
|
| 321 |
/** |
| 322 |
* Delegate to `\WP_CLI::runcommand()` when the request is already running |
| 323 |
* inside a WP-CLI process. |
| 324 |
* |
| 325 |
* @param string $command Subcommand string (no leading "wp"). |
| 326 |
* @return array<string,mixed> |
| 327 |
*/ |
| 328 |
private function run_via_wpcli_api( string $command ): array { |
| 329 |
try { |
| 330 |
/** |
| 331 |
* Narrowed type for `$result`. |
| 332 |
* |
| 333 |
* @var object{return_code:int,stdout:string,stderr:string} $result |
| 334 |
*/ |
| 335 |
$result = \WP_CLI::runcommand( |
| 336 |
$command, |
| 337 |
array( |
| 338 |
'launch' => false, |
| 339 |
'return' => 'all', |
| 340 |
'exit_error' => false, |
| 341 |
) |
| 342 |
); |
| 343 |
} catch ( \Throwable $e ) { |
| 344 |
// Internal fault in WP-CLI itself (not a command's own non-zero exit — |
| 345 |
// that's handled below with the real stderr the agent needs). The raw |
| 346 |
// exception text can leak class names / paths, so keep it server-side |
| 347 |
// (WP_DEBUG) and return a static message. |
| 348 |
Utils::debug_log( 'WP-CLI runtime fault', $e->getMessage() ); |
| 349 |
return Response::error( 'WP-CLI could not run the command. Please try again.' ); |
| 350 |
} |
| 351 |
|
| 352 |
if ( 0 !== $result->return_code ) { |
| 353 |
$error_raw = '' !== trim( $result->stderr ) ? $result->stderr : $result->stdout; |
| 354 |
$error = trim( $error_raw ); |
| 355 |
if ( '' === $error ) { |
| 356 |
$error = sprintf( 'Command failed (exit %d)', $result->return_code ); |
| 357 |
} |
| 358 |
return Response::error( $error ); |
| 359 |
} |
| 360 |
|
| 361 |
$stdout = trim( $result->stdout ); |
| 362 |
$is_truncated = false; |
| 363 |
|
| 364 |
if ( strlen( $stdout ) >= self::MAX_OUTPUT_BYTES ) { |
| 365 |
$stdout = substr( $stdout, 0, self::MAX_OUTPUT_BYTES ); |
| 366 |
$is_truncated = true; |
| 367 |
} |
| 368 |
|
| 369 |
/** |
| 370 |
* Parsed command output. |
| 371 |
* |
| 372 |
* @var array<string,mixed> $parsed |
| 373 |
*/ |
| 374 |
$parsed = $this->parse_output( $stdout ); |
| 375 |
if ( $is_truncated ) { |
| 376 |
$parsed['truncated'] = true; |
| 377 |
$parsed['truncated_message'] = 'Output truncated. Use --format=json with pagination flags to page through results.'; |
| 378 |
} |
| 379 |
return $parsed; |
| 380 |
} |
| 381 |
|
| 382 |
// ─── Native dispatcher ────────────────────────────────────────────────────── |
| 383 |
|
| 384 |
/** |
| 385 |
* Dispatch an allowlisted WP-CLI command to a native WordPress handler. |
| 386 |
* |
| 387 |
* @param string[] $args Parsed command tokens (no leading "wp"). |
| 388 |
* @return array<string,mixed> |
| 389 |
*/ |
| 390 |
private function dispatch_native( array $args ): array { |
| 391 |
list( $positional, $flags ) = $this->parse_flags( $args ); |
| 392 |
|
| 393 |
$base = strtolower( $positional[0] ?? '' ); |
| 394 |
$sub = strtolower( $positional[1] ?? '' ); |
| 395 |
|
| 396 |
// Single-token commands whose first positional is the command itself |
| 397 |
// (rather than a "<base> <sub>" pair). Branch before the switch so the |
| 398 |
// switch's "$base $sub" key doesn't need to encode the search/replace |
| 399 |
// arguments. |
| 400 |
if ( 'search-replace' === $base ) { |
| 401 |
return $this->handle_search_replace( array_slice( $positional, 1 ), $flags ); |
| 402 |
} |
| 403 |
|
| 404 |
// Routes where the subcommand is a single word after the base. |
| 405 |
$route = trim( "{$base} {$sub}" ); |
| 406 |
$rest = array_slice( $positional, 2 ); |
| 407 |
|
| 408 |
switch ( $route ) { |
| 409 |
// Plugin. |
| 410 |
case 'plugin list': |
| 411 |
return $this->handle_plugin_list( $flags ); |
| 412 |
case 'plugin get': |
| 413 |
return $this->handle_plugin_get( $rest, $flags ); |
| 414 |
case 'plugin is-active': |
| 415 |
case 'plugin is_active': |
| 416 |
return $this->handle_plugin_is_active( $rest ); |
| 417 |
|
| 418 |
// Plugin lifecycle (install/activate/deactivate/delete) is handled by |
| 419 |
// dedicated browser-proxied abilities. Refuse here with an explicit |
| 420 |
// pointer so the LLM picks the right tool instead of running |
| 421 |
// `wp plugin activate ...` and falling through to a stub. |
| 422 |
case 'plugin install': |
| 423 |
return Response::error( |
| 424 |
'`wp plugin install` is not available here. Use the dedicated `zipai/install-plugin` ability — it installs through the user\'s browser session under the user\'s real `install_plugins` capability.' |
| 425 |
); |
| 426 |
case 'plugin activate': |
| 427 |
return Response::error( |
| 428 |
'`wp plugin activate` is not available here. Use the dedicated `zipai/activate-plugin` ability — it activates through the user\'s browser session under the user\'s real `activate_plugins` capability.' |
| 429 |
); |
| 430 |
case 'plugin deactivate': |
| 431 |
return Response::error( |
| 432 |
'`wp plugin deactivate` is not available here. Use the dedicated `zipai/deactivate-plugin` ability — it deactivates through the user\'s browser session under the user\'s real `activate_plugins` capability.' |
| 433 |
); |
| 434 |
case 'plugin delete': |
| 435 |
return Response::error( |
| 436 |
'`wp plugin delete` is not available here. Use the dedicated `zipai/delete-plugin` ability — it deletes through the user\'s browser session under the user\'s real `delete_plugins` capability.' |
| 437 |
); |
| 438 |
case 'plugin update': |
| 439 |
return Response::error( |
| 440 |
'`wp plugin update` is not available here. Use the dedicated `zipai/update-plugin` ability — it updates server-side under the user\'s real `update_plugins` capability and verifies each version bump.' |
| 441 |
); |
| 442 |
|
| 443 |
// Theme. |
| 444 |
case 'theme list': |
| 445 |
return $this->handle_theme_list( $flags ); |
| 446 |
case 'theme get': |
| 447 |
case 'theme status': |
| 448 |
return $this->handle_theme_get( $rest, $flags ); |
| 449 |
case 'theme activate': |
| 450 |
return $this->handle_theme_activate( $rest ); |
| 451 |
case 'theme install': |
| 452 |
return Response::error( |
| 453 |
'`wp theme install` is not available here. Use the dedicated `zipai/install-theme` ability — it installs through the user\'s browser session under the user\'s real `install_themes` capability.' |
| 454 |
); |
| 455 |
case 'theme delete': |
| 456 |
return Response::error( |
| 457 |
'`wp theme delete` is not available here. Use the dedicated `zipai/delete-theme` ability — it deletes through the user\'s browser session under the user\'s real `delete_themes` capability.' |
| 458 |
); |
| 459 |
case 'theme update': |
| 460 |
return Response::error( |
| 461 |
'`wp theme update` is not available here. Use the dedicated `zipai/update-theme` ability — it updates server-side under the user\'s real `update_themes` capability and verifies each version bump.' |
| 462 |
); |
| 463 |
|
| 464 |
// Option. Reads + targeted writes (update / delete) are allowed. |
| 465 |
// `add` / `patch` stay blocked in verify_command_security — `update` |
| 466 |
// is idempotent and covers the legitimate write surface. Protected |
| 467 |
// keys (Protected_Options_Filter::write_protected_keys()) refuse upfront. |
| 468 |
case 'option get': |
| 469 |
return $this->handle_option_get( $rest, $flags ); |
| 470 |
case 'option list': |
| 471 |
return $this->handle_option_list( $flags ); |
| 472 |
case 'option update': |
| 473 |
return $this->handle_option_update( $rest, $flags ); |
| 474 |
case 'option delete': |
| 475 |
return $this->handle_option_delete( $rest ); |
| 476 |
|
| 477 |
// Transient — `set`/`patch` and arbitrary-key `delete` are blocked in |
| 478 |
// verify_command_security; the four cases below are the safe ones. |
| 479 |
case 'transient get': |
| 480 |
return $this->handle_transient_get( $rest ); |
| 481 |
case 'transient list': |
| 482 |
return $this->handle_transient_list( $flags ); |
| 483 |
case 'transient type': |
| 484 |
return $this->handle_transient_type(); |
| 485 |
case 'transient delete': |
| 486 |
return $this->handle_transient_delete( $flags ); |
| 487 |
|
| 488 |
// Post. |
| 489 |
case 'post list': |
| 490 |
return $this->handle_post_list( $flags ); |
| 491 |
case 'post get': |
| 492 |
return $this->handle_post_get( $rest, $flags ); |
| 493 |
case 'post create': |
| 494 |
return $this->handle_post_create( $flags ); |
| 495 |
case 'post update': |
| 496 |
return $this->handle_post_update( $rest, $flags ); |
| 497 |
case 'post delete': |
| 498 |
return $this->handle_post_delete( $rest, $flags ); |
| 499 |
|
| 500 |
// Post meta — 3 tokens: `post meta <verb> ...`. |
| 501 |
case 'post meta': |
| 502 |
return $this->route_meta( 'post', $rest, $flags ); |
| 503 |
|
| 504 |
// Comment. |
| 505 |
case 'comment list': |
| 506 |
return $this->handle_comment_list( $flags ); |
| 507 |
case 'comment get': |
| 508 |
return $this->handle_comment_get( $rest, $flags ); |
| 509 |
case 'comment count': |
| 510 |
return $this->handle_comment_count( $rest ); |
| 511 |
case 'comment exists': |
| 512 |
return $this->handle_comment_exists( $rest ); |
| 513 |
case 'comment status': |
| 514 |
return $this->handle_comment_status( $rest ); |
| 515 |
case 'comment create': |
| 516 |
return $this->handle_comment_create( $flags ); |
| 517 |
case 'comment update': |
| 518 |
return $this->handle_comment_update( $rest, $flags ); |
| 519 |
case 'comment delete': |
| 520 |
return $this->handle_comment_delete( $rest, $flags ); |
| 521 |
case 'comment approve': |
| 522 |
case 'comment unapprove': |
| 523 |
case 'comment spam': |
| 524 |
case 'comment unspam': |
| 525 |
case 'comment trash': |
| 526 |
case 'comment untrash': |
| 527 |
return $this->handle_comment_set_status( $sub, $rest ); |
| 528 |
case 'comment recount': |
| 529 |
return $this->handle_comment_recount( $rest ); |
| 530 |
|
| 531 |
// Comment meta — reads only (writes blocked in verify_command_security). |
| 532 |
case 'comment meta': |
| 533 |
return $this->route_meta( 'comment', $rest, $flags ); |
| 534 |
|
| 535 |
// User. |
| 536 |
case 'user list': |
| 537 |
return $this->handle_user_list( $flags ); |
| 538 |
case 'user get': |
| 539 |
return $this->handle_user_get( $rest, $flags ); |
| 540 |
case 'user meta': |
| 541 |
return $this->route_meta( 'user', $rest, $flags ); |
| 542 |
case 'user add-role': |
| 543 |
return $this->handle_user_role_change( 'add', $rest ); |
| 544 |
case 'user remove-role': |
| 545 |
return $this->handle_user_role_change( 'remove', $rest ); |
| 546 |
case 'user set-role': |
| 547 |
return $this->handle_user_role_change( 'set', $rest ); |
| 548 |
case 'user add-cap': |
| 549 |
return $this->handle_user_cap_change( 'add', $rest ); |
| 550 |
case 'user remove-cap': |
| 551 |
return $this->handle_user_cap_change( 'remove', $rest ); |
| 552 |
|
| 553 |
// Term. |
| 554 |
case 'term list': |
| 555 |
return $this->handle_term_list( $rest, $flags ); |
| 556 |
|
| 557 |
// Post type / taxonomy schema discovery. |
| 558 |
case 'post-type list': |
| 559 |
case 'post_type list': |
| 560 |
return $this->handle_post_type_list( $flags ); |
| 561 |
case 'taxonomy list': |
| 562 |
return $this->handle_taxonomy_list( $flags ); |
| 563 |
|
| 564 |
// Menu. |
| 565 |
case 'menu list': |
| 566 |
return $this->handle_menu_list( $flags ); |
| 567 |
case 'menu create': |
| 568 |
return $this->handle_menu_create( $rest ); |
| 569 |
case 'menu item': |
| 570 |
return $this->route_menu_item( $rest, $flags ); |
| 571 |
|
| 572 |
// Sidebar / widget. |
| 573 |
case 'sidebar list': |
| 574 |
return $this->handle_sidebar_list(); |
| 575 |
case 'widget list': |
| 576 |
return $this->handle_widget_list( $rest ); |
| 577 |
|
| 578 |
// Core / language / misc. |
| 579 |
case 'core version': |
| 580 |
return Response::success( array( 'version' => get_bloginfo( 'version' ) ) ); |
| 581 |
case 'core check-update': |
| 582 |
case 'core check_update': |
| 583 |
return $this->handle_core_check_update(); |
| 584 |
|
| 585 |
// Core UPDATES are deliberately unsupported — no route, no ability. |
| 586 |
// An explicit refusal beats the generic catch-all so the LLM relays |
| 587 |
// the manual path instead of hunting for a fallback tool. |
| 588 |
case 'core update': |
| 589 |
case 'core upgrade': |
| 590 |
case 'core download': |
| 591 |
return Response::error( |
| 592 |
'WordPress core updates are not supported by the agent — there is no core-update ability and `wp core update` has no web-context route. Ask the user to apply the update in wp-admin → Dashboard → Updates.' |
| 593 |
); |
| 594 |
|
| 595 |
// CLI runtime probe — reports the PHP/WP process serving the |
| 596 |
// request (there is no WP-CLI binary in web/REST context). |
| 597 |
case 'cli info': |
| 598 |
return $this->handle_cli_info(); |
| 599 |
|
| 600 |
case 'language core': |
| 601 |
if ( 'list' === ( $positional[2] ?? '' ) ) { |
| 602 |
return Response::success( get_available_languages() ); |
| 603 |
} |
| 604 |
return Response::error( sprintf( 'Unsupported subcommand: "language core %s".', $positional[2] ?? '' ) ); |
| 605 |
case 'cache flush': |
| 606 |
wp_cache_flush(); |
| 607 |
return Response::success( array( 'success' => true ) ); |
| 608 |
case 'rewrite flush': |
| 609 |
// Soft flush: drop the cached rewrite_rules option so WordPress |
| 610 |
// regenerates rules on the next request. Equivalent to passing |
| 611 |
// false to flush_rewrite_rules() but without writing .htaccess |
| 612 |
// or hitting the VIP restricted-functions sniff. |
| 613 |
delete_option( 'rewrite_rules' ); |
| 614 |
return Response::success( array( 'success' => true ) ); |
| 615 |
case 'cron event': |
| 616 |
if ( 'list' === ( $positional[2] ?? '' ) ) { |
| 617 |
return $this->handle_cron_event_list(); |
| 618 |
} |
| 619 |
return Response::error( sprintf( 'Unsupported subcommand: "cron event %s".', $positional[2] ?? '' ) ); |
| 620 |
case 'cron schedule': |
| 621 |
if ( 'list' === ( $positional[2] ?? '' ) ) { |
| 622 |
return $this->handle_cron_schedule_list(); |
| 623 |
} |
| 624 |
return Response::error( sprintf( 'Unsupported subcommand: "cron schedule %s".', $positional[2] ?? '' ) ); |
| 625 |
case 'rewrite list': |
| 626 |
return $this->handle_rewrite_list(); |
| 627 |
|
| 628 |
// Diagnostics: role discovery, db size, env info. |
| 629 |
case 'role list': |
| 630 |
return $this->handle_role_list( $flags ); |
| 631 |
case 'role list-caps': |
| 632 |
return $this->handle_role_list_caps( $rest ); |
| 633 |
case 'db size': |
| 634 |
return $this->handle_db_size( $flags ); |
| 635 |
case 'env': |
| 636 |
return $this->handle_env(); |
| 637 |
} |
| 638 |
|
| 639 |
return Response::error( |
| 640 |
sprintf( |
| 641 |
'Command "%s" is not available in web/REST context on the WordPress.org build. Use the REST or Abilities API for uncovered operations.', |
| 642 |
trim( "{$base} {$sub}" ) |
| 643 |
) |
| 644 |
); |
| 645 |
} |
| 646 |
|
| 647 |
// ─── Plugin handlers ──────────────────────────────────────────────────────── |
| 648 |
|
| 649 |
/** |
| 650 |
* Handle "plugin list". |
| 651 |
* |
| 652 |
* @param array<string,mixed> $flags Parsed flags. |
| 653 |
* @return array<string,mixed> |
| 654 |
*/ |
| 655 |
private function handle_plugin_list( array $flags ): array { |
| 656 |
if ( ! function_exists( 'get_plugins' ) ) { |
| 657 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 658 |
} |
| 659 |
// `get_plugin_updates()` reads the cached `update_plugins` site |
| 660 |
// transient (populated by core's scheduled `wp_update_plugins()`) — |
| 661 |
// no forced wp.org call, so it's safe and fast in web/REST context. |
| 662 |
// Without this the `update` / `update_version` columns the caller asks |
| 663 |
// for would always come back empty and a Site Health snapshot would |
| 664 |
// under-report available updates. |
| 665 |
if ( ! function_exists( 'get_plugin_updates' ) ) { |
| 666 |
require_once ABSPATH . 'wp-admin/includes/update.php'; |
| 667 |
} |
| 668 |
$plugin_updates = function_exists( 'get_plugin_updates' ) ? get_plugin_updates() : array(); |
| 669 |
|
| 670 |
$plugins = get_plugins(); |
| 671 |
$status = strtolower( Utils::to_str( $flags['status'] ?? 'all', 'all' ) ); |
| 672 |
|
| 673 |
$out = array(); |
| 674 |
foreach ( $plugins as $file => $data ) { |
| 675 |
$active = is_plugin_active( $file ); |
| 676 |
if ( 'active' === $status && ! $active ) { |
| 677 |
continue; |
| 678 |
} |
| 679 |
if ( 'inactive' === $status && $active ) { |
| 680 |
continue; |
| 681 |
} |
| 682 |
$has_update = isset( $plugin_updates[ $file ] ); |
| 683 |
$update_version = ''; |
| 684 |
if ( $has_update ) { |
| 685 |
$entry = $plugin_updates[ $file ]; |
| 686 |
if ( isset( $entry->update ) && is_object( $entry->update ) && isset( $entry->update->new_version ) ) { |
| 687 |
$update_version = Utils::to_str( $entry->update->new_version ); |
| 688 |
} |
| 689 |
} |
| 690 |
$out[] = array( |
| 691 |
'name' => sanitize_title( Utils::to_str( $data['Name'] ?? basename( dirname( $file ) ) ) ), |
| 692 |
'file' => $file, |
| 693 |
'title' => $data['Name'] ?? '', |
| 694 |
'status' => $active ? 'active' : 'inactive', |
| 695 |
'version' => $data['Version'] ?? '', |
| 696 |
// Mirror WP-CLI's `wp plugin list` columns: 'available' | 'none'. |
| 697 |
'update' => $has_update ? 'available' : 'none', |
| 698 |
'update_version' => $update_version, |
| 699 |
'description' => wp_trim_words( wp_strip_all_tags( Utils::to_str( $data['Description'] ?? '' ) ), 30 ), |
| 700 |
'author' => wp_strip_all_tags( Utils::to_str( $data['Author'] ?? '' ) ), |
| 701 |
); |
| 702 |
} |
| 703 |
return Response::success( $out ); |
| 704 |
} |
| 705 |
|
| 706 |
/** |
| 707 |
* Handle "plugin get <slug>". |
| 708 |
* |
| 709 |
* @param string[] $positional Remaining positional args. |
| 710 |
* @param array<string,mixed> $flags Parsed flags. |
| 711 |
* @return array<string,mixed> |
| 712 |
*/ |
| 713 |
private function handle_plugin_get( array $positional, array $flags ) { |
| 714 |
unset( $flags ); |
| 715 |
$slug = $positional[0] ?? ''; |
| 716 |
if ( '' === $slug ) { |
| 717 |
return Response::error( 'Usage: plugin get <slug>' ); |
| 718 |
} |
| 719 |
if ( ! function_exists( 'get_plugins' ) ) { |
| 720 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 721 |
} |
| 722 |
$file = $this->resolve_plugin_file( $slug ); |
| 723 |
if ( null === $file ) { |
| 724 |
return Response::error( sprintf( 'Plugin "%s" not found.', $slug ) ); |
| 725 |
} |
| 726 |
$plugins = get_plugins(); |
| 727 |
$data = $plugins[ $file ] ?? array(); |
| 728 |
return Response::success( |
| 729 |
array( |
| 730 |
'file' => $file, |
| 731 |
'name' => sanitize_title( Utils::to_str( $data['Name'] ?? $slug ) ), |
| 732 |
'title' => $data['Name'] ?? '', |
| 733 |
'status' => is_plugin_active( $file ) ? 'active' : 'inactive', |
| 734 |
'version' => $data['Version'] ?? '', |
| 735 |
'description' => wp_strip_all_tags( Utils::to_str( $data['Description'] ?? '' ) ), |
| 736 |
'author' => wp_strip_all_tags( Utils::to_str( $data['Author'] ?? '' ) ), |
| 737 |
'requires_wp' => $data['RequiresWP'] ?? '', |
| 738 |
'requires_php' => $data['RequiresPHP'] ?? '', |
| 739 |
) |
| 740 |
); |
| 741 |
} |
| 742 |
|
| 743 |
/** |
| 744 |
* Handle "plugin is-active". |
| 745 |
* |
| 746 |
* @param string[] $positional Remaining positional args. |
| 747 |
* @return array<string,mixed> |
| 748 |
*/ |
| 749 |
private function handle_plugin_is_active( array $positional ): array { |
| 750 |
$slug = $positional[0] ?? ''; |
| 751 |
if ( '' === $slug ) { |
| 752 |
return Response::error( 'Usage: plugin is-active <slug>' ); |
| 753 |
} |
| 754 |
if ( ! function_exists( 'is_plugin_active' ) ) { |
| 755 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 756 |
} |
| 757 |
$file = $this->resolve_plugin_file( $slug ); |
| 758 |
if ( null === $file ) { |
| 759 |
return Response::success( array( 'active' => false ) ); |
| 760 |
} |
| 761 |
return Response::success( array( 'active' => is_plugin_active( $file ) ) ); |
| 762 |
} |
| 763 |
|
| 764 |
/** |
| 765 |
* Resolve a plugin slug or folder name to its main plugin file. |
| 766 |
* |
| 767 |
* @param string $slug Slug or "folder/file.php". |
| 768 |
* @return string|null Plugin file relative to plugins/, or null if not found. |
| 769 |
*/ |
| 770 |
private function resolve_plugin_file( string $slug ): ?string { |
| 771 |
if ( ! function_exists( 'get_plugins' ) ) { |
| 772 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 773 |
} |
| 774 |
$plugins = get_plugins(); |
| 775 |
if ( isset( $plugins[ $slug ] ) ) { |
| 776 |
return $slug; |
| 777 |
} |
| 778 |
foreach ( array_keys( $plugins ) as $file ) { |
| 779 |
if ( 0 === strpos( $file, $slug . '/' ) || 0 === strcasecmp( dirname( $file ), $slug ) ) { |
| 780 |
return $file; |
| 781 |
} |
| 782 |
} |
| 783 |
return null; |
| 784 |
} |
| 785 |
|
| 786 |
// ─── Theme handlers ───────────────────────────────────────────────────────── |
| 787 |
|
| 788 |
/** |
| 789 |
* Handle "theme list". |
| 790 |
* |
| 791 |
* @param array<string,mixed> $flags Parsed flags. |
| 792 |
* @return array<string,mixed> |
| 793 |
*/ |
| 794 |
private function handle_theme_list( array $flags ): array { |
| 795 |
$status = strtolower( Utils::to_str( $flags['status'] ?? 'all', 'all' ) ); |
| 796 |
$current = get_option( 'stylesheet' ); |
| 797 |
|
| 798 |
// Cached theme-update data (`update_themes` transient) — same |
| 799 |
// rationale as plugin updates: read-only, no forced wp.org call, |
| 800 |
// keeps the `update` / `update_version` columns populated. |
| 801 |
if ( ! function_exists( 'get_theme_updates' ) ) { |
| 802 |
require_once ABSPATH . 'wp-admin/includes/update.php'; |
| 803 |
} |
| 804 |
$theme_updates = function_exists( 'get_theme_updates' ) ? get_theme_updates() : array(); |
| 805 |
|
| 806 |
$out = array(); |
| 807 |
foreach ( wp_get_themes() as $stylesheet => $theme ) { |
| 808 |
$is_active = ( $stylesheet === $current ); |
| 809 |
if ( 'active' === $status && ! $is_active ) { |
| 810 |
continue; |
| 811 |
} |
| 812 |
if ( 'inactive' === $status && $is_active ) { |
| 813 |
continue; |
| 814 |
} |
| 815 |
// `get_theme_updates()` returns WP_Theme objects with an `update` |
| 816 |
// array property (keyed by stylesheet) when an update is offered. |
| 817 |
$has_update = isset( $theme_updates[ $stylesheet ] ); |
| 818 |
$update_version = ''; |
| 819 |
if ( $has_update ) { |
| 820 |
$entry = $theme_updates[ $stylesheet ]; |
| 821 |
// get_theme_updates() sets ->update to the update-data array; the |
| 822 |
// WP_Theme stub types that dynamic property as bool, so capture it |
| 823 |
// before checking. |
| 824 |
/** |
| 825 |
* Narrowed type for `$update_data`. |
| 826 |
* |
| 827 |
* @var array<string,mixed>|bool $update_data |
| 828 |
*/ |
| 829 |
$update_data = $entry->update; |
| 830 |
if ( is_array( $update_data ) && isset( $update_data['new_version'] ) ) { |
| 831 |
$update_version = Utils::to_str( $update_data['new_version'] ); |
| 832 |
} |
| 833 |
} |
| 834 |
$out[] = array( |
| 835 |
'name' => $stylesheet, |
| 836 |
'title' => $theme->get( 'Name' ), |
| 837 |
'status' => $is_active ? 'active' : 'inactive', |
| 838 |
'version' => $theme->get( 'Version' ), |
| 839 |
'update' => $has_update ? 'available' : 'none', |
| 840 |
'update_version' => $update_version, |
| 841 |
'author' => wp_strip_all_tags( (string) $theme->get( 'Author' ) ), |
| 842 |
); |
| 843 |
} |
| 844 |
return Response::success( $out ); |
| 845 |
} |
| 846 |
|
| 847 |
/** |
| 848 |
* Handle "theme get". |
| 849 |
* |
| 850 |
* @param string[] $positional Remaining positional args. |
| 851 |
* @param array<string,mixed> $flags Parsed flags. |
| 852 |
* @return array<string,mixed> |
| 853 |
*/ |
| 854 |
private function handle_theme_get( array $positional, array $flags ) { |
| 855 |
unset( $flags ); |
| 856 |
$slug = $positional[0] ?? ''; |
| 857 |
$theme = '' === $slug ? wp_get_theme() : wp_get_theme( $slug ); |
| 858 |
if ( ! $theme->exists() ) { |
| 859 |
return Response::error( sprintf( 'Theme "%s" not found.', $slug ) ); |
| 860 |
} |
| 861 |
return Response::success( |
| 862 |
array( |
| 863 |
'name' => $theme->get_stylesheet(), |
| 864 |
'title' => $theme->get( 'Name' ), |
| 865 |
'status' => ( $theme->get_stylesheet() === get_option( 'stylesheet' ) ) ? 'active' : 'inactive', |
| 866 |
'version' => $theme->get( 'Version' ), |
| 867 |
'author' => wp_strip_all_tags( (string) $theme->get( 'Author' ) ), |
| 868 |
'description' => wp_strip_all_tags( (string) $theme->get( 'Description' ) ), |
| 869 |
'parent' => $theme->parent() ? $theme->parent()->get_stylesheet() : '', |
| 870 |
'template' => $theme->get_template(), |
| 871 |
) |
| 872 |
); |
| 873 |
} |
| 874 |
|
| 875 |
/** |
| 876 |
* Handle "theme activate". |
| 877 |
* |
| 878 |
* @param string[] $positional Remaining positional args. |
| 879 |
* @return array<string,mixed> |
| 880 |
*/ |
| 881 |
private function handle_theme_activate( array $positional ): array { |
| 882 |
$slug = $positional[0] ?? ''; |
| 883 |
if ( '' === $slug ) { |
| 884 |
return Response::error( 'Usage: theme activate <slug>' ); |
| 885 |
} |
| 886 |
$theme = wp_get_theme( $slug ); |
| 887 |
if ( ! $theme->exists() ) { |
| 888 |
return Response::error( sprintf( 'Theme "%s" not found.', $slug ) ); |
| 889 |
} |
| 890 |
switch_theme( $slug ); |
| 891 |
return Response::success( |
| 892 |
array( |
| 893 |
'activated' => $slug, |
| 894 |
) |
| 895 |
); |
| 896 |
} |
| 897 |
|
| 898 |
// ─── Option handlers ──────────────────────────────────────────────────────── |
| 899 |
|
| 900 |
/** |
| 901 |
* Handle "option get". |
| 902 |
* |
| 903 |
* @param string[] $positional Remaining positional args. |
| 904 |
* @param array<string,mixed> $flags Parsed flags. |
| 905 |
* @return array<string,mixed> |
| 906 |
*/ |
| 907 |
private function handle_option_get( array $positional, array $flags ) { |
| 908 |
unset( $flags ); |
| 909 |
if ( empty( $positional ) ) { |
| 910 |
return Response::error( 'Usage: option get <key> [<key2> …]' ); |
| 911 |
} |
| 912 |
$out = array(); |
| 913 |
foreach ( $positional as $key ) { |
| 914 |
$out[ $key ] = get_option( $key ); |
| 915 |
} |
| 916 |
return Response::success( 1 === count( $out ) ? reset( $out ) : $out ); |
| 917 |
} |
| 918 |
|
| 919 |
/** |
| 920 |
* Handle "option list". |
| 921 |
* |
| 922 |
* @param array<string,mixed> $flags Parsed flags. |
| 923 |
* @return array<string,mixed> |
| 924 |
*/ |
| 925 |
private function handle_option_list( array $flags ): array { |
| 926 |
global $wpdb; |
| 927 |
/** |
| 928 |
* Narrowed type for `$wpdb`. |
| 929 |
* |
| 930 |
* @var \wpdb $wpdb |
| 931 |
*/ |
| 932 |
$search = Utils::to_str( $flags['search'] ?? '' ); |
| 933 |
$limit = Utils::to_int( $flags['limit'] ?? 200, 200 ); |
| 934 |
$limit = max( 1, min( $limit, 1000 ) ); |
| 935 |
|
| 936 |
// --autoload=on|off|yes|no — useful for performance audits ("what's |
| 937 |
// loaded on every request?"). Maps to the `yes`/`no` values stored in |
| 938 |
// the wp_options.autoload column. |
| 939 |
$autoload_filter = ''; |
| 940 |
if ( isset( $flags['autoload'] ) ) { |
| 941 |
$raw = strtolower( Utils::to_str( $flags['autoload'] ) ); |
| 942 |
if ( in_array( $raw, array( 'on', 'yes', 'true', '1' ), true ) ) { |
| 943 |
$autoload_filter = 'yes'; |
| 944 |
} elseif ( in_array( $raw, array( 'off', 'no', 'false', '0' ), true ) ) { |
| 945 |
$autoload_filter = 'no'; |
| 946 |
} |
| 947 |
} |
| 948 |
|
| 949 |
$sql = 'SELECT option_name, autoload FROM %i'; |
| 950 |
$bindings = array( $wpdb->options ); |
| 951 |
if ( '' !== $search && '' !== $autoload_filter ) { |
| 952 |
$sql .= ' WHERE option_name LIKE %s AND autoload = %s'; |
| 953 |
$bindings[] = '%' . $wpdb->esc_like( $search ) . '%'; |
| 954 |
$bindings[] = $autoload_filter; |
| 955 |
} elseif ( '' !== $search ) { |
| 956 |
$sql .= ' WHERE option_name LIKE %s'; |
| 957 |
$bindings[] = '%' . $wpdb->esc_like( $search ) . '%'; |
| 958 |
} elseif ( '' !== $autoload_filter ) { |
| 959 |
$sql .= ' WHERE autoload = %s'; |
| 960 |
$bindings[] = $autoload_filter; |
| 961 |
} |
| 962 |
$sql .= ' ORDER BY option_name ASC LIMIT %d'; |
| 963 |
$bindings[] = $limit; |
| 964 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query is built from literal fragments + %i/%d placeholders and run through $wpdb->prepare(). |
| 965 |
$rows = $wpdb->get_results( $wpdb->prepare( $sql, $bindings ), ARRAY_A ); |
| 966 |
|
| 967 |
return Response::success( is_array( $rows ) ? $rows : array() ); |
| 968 |
} |
| 969 |
|
| 970 |
/** |
| 971 |
* Handle "option update <key> <value>". |
| 972 |
* |
| 973 |
* Mirrors WP-CLI's `wp option update <key> <value> [--format=json]`: |
| 974 |
* - Idempotent: it creates the option when the option is missing. |
| 975 |
* - `--format=json`: it parses `<value>` as JSON. So callers can pass |
| 976 |
* arrays or objects. They do not serialize by hand. |
| 977 |
* |
| 978 |
* The code refuses keys in |
| 979 |
* `Protected_Options_Filter::write_protected_keys()` upfront with a clear |
| 980 |
* error. These are brick-risk options. They also include keys this generic |
| 981 |
* primitive must not reach (`active_plugins`, `template`, `stylesheet`, |
| 982 |
* `upload_path`, `upload_url_path`, `{prefix}user_roles`). |
| 983 |
* `Protected_Options_Filter` also intercepts the `update_option()` call. |
| 984 |
* This is a second backstop for the filter-installed subset. The upfront |
| 985 |
* check means the code never claims success on a write that the filter |
| 986 |
* turned into a no-op. That failure mode broke theme-activate before the |
| 987 |
* protect-list fix. |
| 988 |
* |
| 989 |
* @param string[] $positional Remaining positional args ([key, value]). |
| 990 |
* @param array<string,mixed> $flags Parsed flags (`--format=json` recognised). |
| 991 |
* @return array<string,mixed> |
| 992 |
*/ |
| 993 |
private function handle_option_update( array $positional, array $flags ): array { |
| 994 |
$key = isset( $positional[0] ) ? (string) $positional[0] : ''; |
| 995 |
if ( '' === $key ) { |
| 996 |
return Response::error( 'Usage: option update <key> <value> [--format=json]' ); |
| 997 |
} |
| 998 |
|
| 999 |
if ( \ZipAI\MCP\Classes\Security\Protected_Options_Filter::is_write_protected( $key ) ) { |
| 1000 |
return Response::error( |
| 1001 |
sprintf( |
| 1002 |
'Security policy: option "%s" is protected and cannot be modified via the AI agent. Edit manually in wp-admin → Settings, or use the dedicated ability for it (theme activate / plugin activate).', |
| 1003 |
$key |
| 1004 |
) |
| 1005 |
); |
| 1006 |
} |
| 1007 |
|
| 1008 |
// Value: positional[1] is the raw string. `--format=json` reinterprets |
| 1009 |
// it as a JSON literal (lets callers store arrays/objects). |
| 1010 |
if ( ! array_key_exists( 1, $positional ) ) { |
| 1011 |
return Response::error( 'Usage: option update <key> <value> [--format=json]' ); |
| 1012 |
} |
| 1013 |
$value = $positional[1]; |
| 1014 |
if ( isset( $flags['format'] ) && 'json' === strtolower( Utils::to_str( $flags['format'] ) ) ) { |
| 1015 |
$decoded = json_decode( $value, true ); |
| 1016 |
if ( null === $decoded && JSON_ERROR_NONE !== json_last_error() ) { |
| 1017 |
return Response::error( sprintf( 'Invalid JSON value: %s.', json_last_error_msg() ) ); |
| 1018 |
} |
| 1019 |
$value = $decoded; |
| 1020 |
} |
| 1021 |
|
| 1022 |
$ok = update_option( $key, $value ); |
| 1023 |
|
| 1024 |
// `update_option()` returns false for BOTH "value already at this |
| 1025 |
// state" and "filter blocked the write" — indistinguishable from |
| 1026 |
// the return value alone. Read back and compare to catch the |
| 1027 |
// latter case: if the post-write value still differs from what we |
| 1028 |
// tried to write, a `pre_update_option_<key>` filter (security |
| 1029 |
// plugin / theme / other code) refused it. |
| 1030 |
$post = get_option( $key ); |
| 1031 |
if ( ! $ok && \maybe_serialize( $post ) !== \maybe_serialize( $value ) ) { |
| 1032 |
return Response::error( |
| 1033 |
sprintf( |
| 1034 |
'Option "%s" did not update — a `pre_update_option_%s` filter (security plugin, theme, or other code) is blocking the write.', |
| 1035 |
$key, |
| 1036 |
$key |
| 1037 |
) |
| 1038 |
); |
| 1039 |
} |
| 1040 |
|
| 1041 |
return Response::success( |
| 1042 |
array( |
| 1043 |
'key' => $key, |
| 1044 |
'updated' => (bool) $ok, |
| 1045 |
'message' => $ok |
| 1046 |
? sprintf( 'Option "%s" updated.', $key ) |
| 1047 |
: sprintf( 'Option "%s" already at this value; no write.', $key ), |
| 1048 |
) |
| 1049 |
); |
| 1050 |
} |
| 1051 |
|
| 1052 |
/** |
| 1053 |
* Handle "option delete <key>". |
| 1054 |
* |
| 1055 |
* Idempotent on missing options (returns success with `deleted: false`). |
| 1056 |
* Brick-risk keys refuse upfront — see handle_option_update for rationale. |
| 1057 |
* |
| 1058 |
* @param string[] $positional Remaining positional args ([key]). |
| 1059 |
* @return array<string,mixed> |
| 1060 |
*/ |
| 1061 |
private function handle_option_delete( array $positional ): array { |
| 1062 |
$key = isset( $positional[0] ) ? (string) $positional[0] : ''; |
| 1063 |
if ( '' === $key ) { |
| 1064 |
return Response::error( 'Usage: option delete <key>' ); |
| 1065 |
} |
| 1066 |
|
| 1067 |
if ( \ZipAI\MCP\Classes\Security\Protected_Options_Filter::is_write_protected( $key ) ) { |
| 1068 |
return Response::error( |
| 1069 |
sprintf( |
| 1070 |
'Security policy: option "%s" is protected and cannot be deleted via the AI agent.', |
| 1071 |
$key |
| 1072 |
) |
| 1073 |
); |
| 1074 |
} |
| 1075 |
|
| 1076 |
// `false` from `get_option` with no default = option doesn't exist. |
| 1077 |
// Idempotent: treat absent-option as a successful no-op. |
| 1078 |
if ( false === get_option( $key, false ) ) { |
| 1079 |
return Response::success( |
| 1080 |
array( |
| 1081 |
'key' => $key, |
| 1082 |
'deleted' => false, |
| 1083 |
'message' => sprintf( 'Option "%s" did not exist; nothing to delete.', $key ), |
| 1084 |
) |
| 1085 |
); |
| 1086 |
} |
| 1087 |
|
| 1088 |
$ok = delete_option( $key ); |
| 1089 |
if ( ! $ok ) { |
| 1090 |
return Response::error( sprintf( 'Failed to delete option "%s".', $key ) ); |
| 1091 |
} |
| 1092 |
|
| 1093 |
return Response::success( |
| 1094 |
array( |
| 1095 |
'key' => $key, |
| 1096 |
'deleted' => true, |
| 1097 |
'message' => sprintf( 'Option "%s" deleted.', $key ), |
| 1098 |
) |
| 1099 |
); |
| 1100 |
} |
| 1101 |
|
| 1102 |
// ─── Transient handlers ───────────────────────────────────────────────────── |
| 1103 |
|
| 1104 |
/** |
| 1105 |
* Handle "transient get". |
| 1106 |
* |
| 1107 |
* @param string[] $positional Remaining positional args. |
| 1108 |
* @return array<string,mixed> |
| 1109 |
*/ |
| 1110 |
private function handle_transient_get( array $positional ): array { |
| 1111 |
$key = $positional[0] ?? ''; |
| 1112 |
if ( '' === $key ) { |
| 1113 |
return Response::error( 'Usage: transient get <key>' ); |
| 1114 |
} |
| 1115 |
return Response::success( array( 'value' => get_transient( $key ) ) ); |
| 1116 |
} |
| 1117 |
|
| 1118 |
/** |
| 1119 |
* Handle "transient list [--search=…] [--limit=…] [--network]". |
| 1120 |
* |
| 1121 |
* Lists transient keys directly from `wp_options` (or `wp_sitemeta` when |
| 1122 |
* `--network` is passed on multisite). Returns only the key name, expiration |
| 1123 |
* timestamp, and seconds-until-expiry — NOT the value, because transient |
| 1124 |
* payloads can be large serialised structures that blow the response cap. |
| 1125 |
* Use `transient get <key>` to fetch a specific value. |
| 1126 |
* |
| 1127 |
* @param array<string,mixed> $flags Parsed flags. |
| 1128 |
* @return array<string,mixed> |
| 1129 |
*/ |
| 1130 |
private function handle_transient_list( array $flags ): array { |
| 1131 |
global $wpdb; |
| 1132 |
/** |
| 1133 |
* Narrowed type for `$wpdb`. |
| 1134 |
* |
| 1135 |
* @var \wpdb $wpdb |
| 1136 |
*/ |
| 1137 |
$is_network = ! empty( $flags['network'] ); |
| 1138 |
if ( $is_network && ! is_multisite() ) { |
| 1139 |
return Response::error( '--network requires a multisite install.' ); |
| 1140 |
} |
| 1141 |
|
| 1142 |
$search = isset( $flags['search'] ) ? Utils::to_str( $flags['search'] ) : ''; |
| 1143 |
$limit = isset( $flags['limit'] ) ? max( 1, min( 1000, Utils::to_int( $flags['limit'] ) ) ) : 200; |
| 1144 |
|
| 1145 |
$prefix = $is_network ? '_site_transient_' : '_transient_'; |
| 1146 |
$timeout_prefix = $is_network ? '_site_transient_timeout_' : '_transient_timeout_'; |
| 1147 |
|
| 1148 |
if ( $is_network ) { |
| 1149 |
$table = (string) $wpdb->sitemeta; |
| 1150 |
$name_column = 'meta_key'; |
| 1151 |
} else { |
| 1152 |
$table = (string) $wpdb->options; |
| 1153 |
$name_column = 'option_name'; |
| 1154 |
} |
| 1155 |
|
| 1156 |
$sql = 'SELECT %i AS k FROM %i WHERE %i LIKE %s AND %i NOT LIKE %s'; |
| 1157 |
$args = array( |
| 1158 |
$name_column, |
| 1159 |
$table, |
| 1160 |
$name_column, |
| 1161 |
$wpdb->esc_like( $prefix ) . '%', |
| 1162 |
$name_column, |
| 1163 |
$wpdb->esc_like( $timeout_prefix ) . '%', |
| 1164 |
); |
| 1165 |
if ( $is_network ) { |
| 1166 |
$sql .= ' AND site_id = %d'; |
| 1167 |
$args[] = get_current_network_id(); |
| 1168 |
} |
| 1169 |
if ( '' !== $search ) { |
| 1170 |
$sql .= ' AND %i LIKE %s'; |
| 1171 |
$args[] = $name_column; |
| 1172 |
$args[] = '%' . $wpdb->esc_like( $search ) . '%'; |
| 1173 |
} |
| 1174 |
$sql .= ' ORDER BY %i ASC LIMIT %d'; |
| 1175 |
$args[] = $name_column; |
| 1176 |
$args[] = $limit; |
| 1177 |
|
| 1178 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query is built from literal fragments + %i/%s/%d placeholders and run through $wpdb->prepare(). |
| 1179 |
$rows = $wpdb->get_col( $wpdb->prepare( $sql, $args ) ); |
| 1180 |
|
| 1181 |
$now = time(); |
| 1182 |
$result = array(); |
| 1183 |
foreach ( (array) $rows as $option_name ) { |
| 1184 |
$key = substr( Utils::to_str( $option_name ), strlen( $prefix ) ); |
| 1185 |
$timeout_option = $timeout_prefix . $key; |
| 1186 |
$expiration = $is_network |
| 1187 |
? get_site_option( $timeout_option ) |
| 1188 |
: get_option( $timeout_option ); |
| 1189 |
$expiration = Utils::to_int( $expiration ); |
| 1190 |
$result[] = array( |
| 1191 |
'key' => $key, |
| 1192 |
'expiration' => $expiration, |
| 1193 |
'expires_in' => $expiration > 0 ? max( 0, $expiration - $now ) : null, |
| 1194 |
'expired' => $expiration > 0 && $expiration < $now, |
| 1195 |
); |
| 1196 |
} |
| 1197 |
|
| 1198 |
return Response::success( |
| 1199 |
array( |
| 1200 |
'scope' => $is_network ? 'site' : 'blog', |
| 1201 |
'count' => count( $result ), |
| 1202 |
'items' => $result, |
| 1203 |
) |
| 1204 |
); |
| 1205 |
} |
| 1206 |
|
| 1207 |
/** |
| 1208 |
* Handle "transient type" — return the storage backend. |
| 1209 |
* |
| 1210 |
* @return array<string,mixed> |
| 1211 |
*/ |
| 1212 |
private function handle_transient_type(): array { |
| 1213 |
return Response::success( |
| 1214 |
array( |
| 1215 |
'storage' => wp_using_ext_object_cache() ? 'object-cache' : 'database', |
| 1216 |
'using_ext_object_cache' => wp_using_ext_object_cache(), |
| 1217 |
'multisite' => is_multisite(), |
| 1218 |
// Hint for the LLM about where `transient list` is currently |
| 1219 |
// reading from — when object-cache is on, the option-table |
| 1220 |
// listing won't see transients that live only in the cache. |
| 1221 |
'list_visibility' => wp_using_ext_object_cache() |
| 1222 |
? 'transient list reads the option/sitemeta table only; transients held entirely in the persistent object cache are NOT enumerated here.' |
| 1223 |
: 'transient list enumerates every site/network transient from the option/sitemeta table.', |
| 1224 |
) |
| 1225 |
); |
| 1226 |
} |
| 1227 |
|
| 1228 |
/** |
| 1229 |
* Handle "transient delete --expired | --all". Specific-key deletion |
| 1230 |
* (`transient delete <key>`) is refused upstream by |
| 1231 |
* `verify_command_security` — the LLM shouldn't be picking individual |
| 1232 |
* transient keys to nuke. |
| 1233 |
* |
| 1234 |
* @param array<string,mixed> $flags Parsed flags. |
| 1235 |
* @return array<string,mixed> |
| 1236 |
*/ |
| 1237 |
private function handle_transient_delete( array $flags ): array { |
| 1238 |
$expired_only = ! empty( $flags['expired'] ); |
| 1239 |
$all = ! empty( $flags['all'] ); |
| 1240 |
|
| 1241 |
// Defence in depth — the security verifier already gates these two cases. |
| 1242 |
if ( ! $expired_only && ! $all ) { |
| 1243 |
return Response::error( 'Usage: transient delete --expired | --all' ); |
| 1244 |
} |
| 1245 |
if ( $expired_only && $all ) { |
| 1246 |
return Response::error( 'Pass either --expired or --all, not both.' ); |
| 1247 |
} |
| 1248 |
|
| 1249 |
if ( $expired_only ) { |
| 1250 |
$deleted = $this->delete_expired_transients( false ); |
| 1251 |
// On multisite also clean network transients for the current network. |
| 1252 |
if ( is_multisite() ) { |
| 1253 |
$deleted += $this->delete_expired_transients( true ); |
| 1254 |
} |
| 1255 |
return Response::success( |
| 1256 |
array( |
| 1257 |
'mode' => 'expired', |
| 1258 |
'transients_freed' => $deleted, |
| 1259 |
'message' => sprintf( 'Deleted %d expired transient entr%s.', $deleted, 1 === $deleted ? 'y' : 'ies' ), |
| 1260 |
) |
| 1261 |
); |
| 1262 |
} |
| 1263 |
|
| 1264 |
// --all: nuke every transient. Mirrors `wp transient delete --all`. |
| 1265 |
$deleted = $this->delete_all_transients( false ); |
| 1266 |
if ( is_multisite() ) { |
| 1267 |
$deleted += $this->delete_all_transients( true ); |
| 1268 |
} |
| 1269 |
if ( wp_using_ext_object_cache() ) { |
| 1270 |
// Transients in the persistent object cache live outside the |
| 1271 |
// options/sitemeta tables; flush the cache so they're gone too. |
| 1272 |
wp_cache_flush(); |
| 1273 |
} |
| 1274 |
return Response::success( |
| 1275 |
array( |
| 1276 |
'mode' => 'all', |
| 1277 |
'transients_freed' => $deleted, |
| 1278 |
'object_cache_flushed' => wp_using_ext_object_cache(), |
| 1279 |
'message' => sprintf( 'Deleted %d transient entr%s.', $deleted, 1 === $deleted ? 'y' : 'ies' ), |
| 1280 |
) |
| 1281 |
); |
| 1282 |
} |
| 1283 |
|
| 1284 |
/** |
| 1285 |
* Delete expired transient + timeout option pairs. |
| 1286 |
* |
| 1287 |
* @param bool $network When true, operate on `_site_transient_*` entries |
| 1288 |
* in $wpdb->sitemeta. Otherwise blog-level options. |
| 1289 |
* @return int Number of `_transient_*` (non-timeout) options removed. |
| 1290 |
*/ |
| 1291 |
private function delete_expired_transients( bool $network ): int { |
| 1292 |
global $wpdb; |
| 1293 |
/** |
| 1294 |
* Narrowed type for `$wpdb`. |
| 1295 |
* |
| 1296 |
* @var \wpdb $wpdb |
| 1297 |
*/ |
| 1298 |
$now = time(); |
| 1299 |
if ( $network ) { |
| 1300 |
$table = (string) $wpdb->sitemeta; |
| 1301 |
$name_column = 'meta_key'; |
| 1302 |
$value_column = 'meta_value'; |
| 1303 |
$timeout_prefix = '_site_transient_timeout_'; |
| 1304 |
$transient_prefix = '_site_transient_'; |
| 1305 |
} else { |
| 1306 |
$table = (string) $wpdb->options; |
| 1307 |
$name_column = 'option_name'; |
| 1308 |
$value_column = 'option_value'; |
| 1309 |
$timeout_prefix = '_transient_timeout_'; |
| 1310 |
$transient_prefix = '_transient_'; |
| 1311 |
} |
| 1312 |
|
| 1313 |
// Find expired timeout entries. |
| 1314 |
$sql = 'SELECT %i FROM %i WHERE %i LIKE %s AND %i < %d'; |
| 1315 |
$args = array( $name_column, $table, $name_column, $wpdb->esc_like( $timeout_prefix ) . '%', $value_column, $now ); |
| 1316 |
if ( $network ) { |
| 1317 |
$sql .= ' AND site_id = %d'; |
| 1318 |
$args[] = get_current_network_id(); |
| 1319 |
} |
| 1320 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query is built from literal fragments + %i/%s/%d placeholders and run through $wpdb->prepare(). |
| 1321 |
$expired_timeout_names = $wpdb->get_col( $wpdb->prepare( $sql, $args ) ); |
| 1322 |
if ( empty( $expired_timeout_names ) ) { |
| 1323 |
return 0; |
| 1324 |
} |
| 1325 |
|
| 1326 |
$deleted = 0; |
| 1327 |
foreach ( $expired_timeout_names as $timeout_option ) { |
| 1328 |
$key = substr( Utils::to_str( $timeout_option ), strlen( $timeout_prefix ) ); |
| 1329 |
$pair = $transient_prefix . $key; |
| 1330 |
if ( $network ) { |
| 1331 |
if ( delete_site_transient( $key ) ) { |
| 1332 |
++$deleted; |
| 1333 |
} else { |
| 1334 |
// Belt-and-braces — drop dangling rows directly. |
| 1335 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 1336 |
$wpdb->delete( |
| 1337 |
$table, |
| 1338 |
array( |
| 1339 |
$name_column => $pair, |
| 1340 |
'site_id' => get_current_network_id(), |
| 1341 |
) |
| 1342 |
); |
| 1343 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 1344 |
$wpdb->delete( |
| 1345 |
$table, |
| 1346 |
array( |
| 1347 |
$name_column => $timeout_option, |
| 1348 |
'site_id' => get_current_network_id(), |
| 1349 |
) |
| 1350 |
); |
| 1351 |
} |
| 1352 |
} else { |
| 1353 |
if ( delete_transient( $key ) ) { |
| 1354 |
++$deleted; |
| 1355 |
} else { |
| 1356 |
delete_option( $pair ); |
| 1357 |
delete_option( Utils::to_str( $timeout_option ) ); |
| 1358 |
} |
| 1359 |
} |
| 1360 |
} |
| 1361 |
return $deleted; |
| 1362 |
} |
| 1363 |
|
| 1364 |
/** |
| 1365 |
* Delete every transient (both `_transient_*` and `_transient_timeout_*`). |
| 1366 |
* |
| 1367 |
* Walks the discovered key list and calls core's `delete_transient()` / |
| 1368 |
* `delete_site_transient()` for each one. Slower than a raw DELETE but |
| 1369 |
* keeps the in-memory option/transient cache coherent — non-autoloaded |
| 1370 |
* transient rows aren't in `alloptions`, so a bulk SQL DELETE alone |
| 1371 |
* would leave stale values in the per-option cache. |
| 1372 |
* |
| 1373 |
* @param bool $network See delete_expired_transients(). |
| 1374 |
* @return int Number of transient entries removed. |
| 1375 |
*/ |
| 1376 |
private function delete_all_transients( bool $network ): int { |
| 1377 |
global $wpdb; |
| 1378 |
/** |
| 1379 |
* Narrowed type for `$wpdb`. |
| 1380 |
* |
| 1381 |
* @var \wpdb $wpdb |
| 1382 |
*/ |
| 1383 |
if ( $network ) { |
| 1384 |
$table = (string) $wpdb->sitemeta; |
| 1385 |
$name_column = 'meta_key'; |
| 1386 |
$prefix = '_site_transient_'; |
| 1387 |
$timeout_prefix = '_site_transient_timeout_'; |
| 1388 |
} else { |
| 1389 |
$table = (string) $wpdb->options; |
| 1390 |
$name_column = 'option_name'; |
| 1391 |
$prefix = '_transient_'; |
| 1392 |
$timeout_prefix = '_transient_timeout_'; |
| 1393 |
} |
| 1394 |
|
| 1395 |
// Discover every transient name (skip the timeout pairs — delete_*_transient |
| 1396 |
// removes them along with the main entry). |
| 1397 |
$sql = 'SELECT %i FROM %i WHERE %i LIKE %s AND %i NOT LIKE %s'; |
| 1398 |
$args = array( $name_column, $table, $name_column, $wpdb->esc_like( $prefix ) . '%', $name_column, $wpdb->esc_like( $timeout_prefix ) . '%' ); |
| 1399 |
if ( $network ) { |
| 1400 |
$sql .= ' AND site_id = %d'; |
| 1401 |
$args[] = get_current_network_id(); |
| 1402 |
} |
| 1403 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- query is built from literal fragments + %i/%s/%d placeholders and run through $wpdb->prepare(). |
| 1404 |
$names = $wpdb->get_col( $wpdb->prepare( $sql, $args ) ); |
| 1405 |
if ( empty( $names ) ) { |
| 1406 |
return 0; |
| 1407 |
} |
| 1408 |
|
| 1409 |
$deleted = 0; |
| 1410 |
foreach ( $names as $option_name ) { |
| 1411 |
$key = substr( Utils::to_str( $option_name ), strlen( $prefix ) ); |
| 1412 |
if ( '' === $key ) { |
| 1413 |
continue; |
| 1414 |
} |
| 1415 |
$ok = $network ? delete_site_transient( $key ) : delete_transient( $key ); |
| 1416 |
if ( $ok ) { |
| 1417 |
++$deleted; |
| 1418 |
} |
| 1419 |
} |
| 1420 |
return $deleted; |
| 1421 |
} |
| 1422 |
|
| 1423 |
// ─── Pagination helpers ───────────────────────────────────────────────────── |
| 1424 |
|
| 1425 |
/** |
| 1426 |
* Resolve the page window (page size + zero-based offset) for a list |
| 1427 |
* command from its flags. |
| 1428 |
* |
| 1429 |
* Accepts both the WP-CLI input style (`--posts_per_page` / `--number`, |
| 1430 |
* `--offset`) and the WP REST style (`--per_page`, `--page`). When an |
| 1431 |
* explicit `--offset` is absent it is derived from `--page` (1-based). |
| 1432 |
* |
| 1433 |
* @param array<string,mixed> $flags Parsed command flags. |
| 1434 |
* @param string $size_flag Primary page-size flag for this |
| 1435 |
* command (`posts_per_page` for |
| 1436 |
* posts, `number` for users/terms). |
| 1437 |
* @param int $default_size Fallback page size when none is given. |
| 1438 |
* @return array{0:int,1:int} [per_page, offset] — both clamped to safe bounds. |
| 1439 |
*/ |
| 1440 |
private function resolve_page_window( array $flags, string $size_flag, int $default_size ): array { |
| 1441 |
$raw = $flags[ $size_flag ] ?? $flags['per_page'] ?? null; |
| 1442 |
|
| 1443 |
// `-1` (or any negative) is the WP-CLI idiom for "all rows". Return the |
| 1444 |
// sentinel -1 so handlers can request an unbounded query instead of |
| 1445 |
// silently clamping the universal "give me everything" request to a |
| 1446 |
// single row. |
| 1447 |
if ( null !== $raw && Utils::to_int( $raw ) < 0 ) { |
| 1448 |
return array( -1, 0 ); |
| 1449 |
} |
| 1450 |
|
| 1451 |
$per_page = Utils::to_int( $raw, $default_size ); |
| 1452 |
$per_page = max( 1, min( $per_page, 1000 ) ); |
| 1453 |
|
| 1454 |
if ( isset( $flags['offset'] ) ) { |
| 1455 |
$offset = max( 0, Utils::to_int( $flags['offset'] ) ); |
| 1456 |
} elseif ( isset( $flags['page'] ) ) { |
| 1457 |
$offset = ( max( 1, Utils::to_int( $flags['page'] ) ) - 1 ) * $per_page; |
| 1458 |
} else { |
| 1459 |
$offset = 0; |
| 1460 |
} |
| 1461 |
|
| 1462 |
return array( $per_page, $offset ); |
| 1463 |
} |
| 1464 |
|
| 1465 |
/** |
| 1466 |
* Wrap a page of rows with WP REST-standard pagination metadata. |
| 1467 |
* |
| 1468 |
* Mirrors the WP REST API contract (the `X-WP-Total` / `X-WP-TotalPages` |
| 1469 |
* headers) in the response body so the agent can page deterministically |
| 1470 |
* with `--page` instead of guessing completeness from row count — a |
| 1471 |
* silently-capped page is otherwise indistinguishable from a full result. |
| 1472 |
* |
| 1473 |
* @param array<int|string,mixed> $items Rows for the current page. |
| 1474 |
* @param int $total Total matching rows across every page. |
| 1475 |
* @param int $per_page Page size actually applied. |
| 1476 |
* @param int $offset Zero-based offset of the current page. |
| 1477 |
* @return array<string,mixed> Standardized success response with `data.pagination`. |
| 1478 |
*/ |
| 1479 |
private function paginated_response( array $items, int $total, int $per_page, int $offset ): array { |
| 1480 |
$per_page = max( 1, $per_page ); |
| 1481 |
|
| 1482 |
return Response::success( |
| 1483 |
$items, |
| 1484 |
array( |
| 1485 |
'pagination' => array( |
| 1486 |
'total' => $total, |
| 1487 |
'total_pages' => (int) ceil( $total / $per_page ), |
| 1488 |
'page' => (int) floor( $offset / $per_page ) + 1, |
| 1489 |
'per_page' => $per_page, |
| 1490 |
'has_more' => ( $offset + count( $items ) ) < $total, |
| 1491 |
), |
| 1492 |
) |
| 1493 |
); |
| 1494 |
} |
| 1495 |
|
| 1496 |
/** |
| 1497 |
* Whether the command requested a bare count (`--format=count`), mirroring |
| 1498 |
* WP-CLI's `--format=count` which prints only the total number of rows. |
| 1499 |
* |
| 1500 |
* @param array<string,mixed> $flags Parsed command flags. |
| 1501 |
* @return bool |
| 1502 |
*/ |
| 1503 |
private function wants_count( array $flags ): bool { |
| 1504 |
return 'count' === strtolower( Utils::to_str( $flags['format'] ?? '' ) ); |
| 1505 |
} |
| 1506 |
|
| 1507 |
/** |
| 1508 |
* Whether the command requested only IDs (`--format=ids`), mirroring |
| 1509 |
* WP-CLI's `--format=ids` which prints just the row identifiers. The |
| 1510 |
* native dispatcher returns them as a flat JSON array so the agent can |
| 1511 |
* feed them straight into a follow-up bulk operation. |
| 1512 |
* |
| 1513 |
* @param array<string,mixed> $flags Parsed command flags. |
| 1514 |
* @return bool |
| 1515 |
*/ |
| 1516 |
private function wants_ids( array $flags ): bool { |
| 1517 |
return 'ids' === strtolower( Utils::to_str( $flags['format'] ?? '' ) ); |
| 1518 |
} |
| 1519 |
|
| 1520 |
/** |
| 1521 |
* Normalize a `--post_status` flag to a value WP_Query understands. |
| 1522 |
* |
| 1523 |
* - `all` → every registered status (incl. trash/draft), matching the |
| 1524 |
* agent's intent of "every post regardless of status". WP_Query has no |
| 1525 |
* native `all`, so left raw it silently matches nothing. |
| 1526 |
* - Comma lists (`publish,draft`) → array, mirroring WP-CLI. |
| 1527 |
* - Anything else (`publish`, `any`, …) → passed through untouched. |
| 1528 |
* |
| 1529 |
* @param mixed $status Raw flag value. |
| 1530 |
* @return string|array<int|string,mixed> |
| 1531 |
*/ |
| 1532 |
private function normalize_post_status( $status ) { |
| 1533 |
if ( is_array( $status ) ) { |
| 1534 |
return $status; |
| 1535 |
} |
| 1536 |
$status = Utils::to_str( $status ); |
| 1537 |
if ( 'all' === strtolower( $status ) ) { |
| 1538 |
return array_values( get_post_stati() ); |
| 1539 |
} |
| 1540 |
if ( false !== strpos( $status, ',' ) ) { |
| 1541 |
return array_values( array_filter( array_map( 'trim', explode( ',', $status ) ), static fn ( string $s ): bool => '' !== $s ) ); |
| 1542 |
} |
| 1543 |
return $status; |
| 1544 |
} |
| 1545 |
|
| 1546 |
// ─── Post handlers ────────────────────────────────────────────────────────── |
| 1547 |
|
| 1548 |
/** |
| 1549 |
* Handle "post list". |
| 1550 |
* |
| 1551 |
* @param array<string,mixed> $flags Parsed flags. |
| 1552 |
* @return array<string,mixed> |
| 1553 |
*/ |
| 1554 |
private function handle_post_list( array $flags ): array { |
| 1555 |
$post_type = Utils::to_str( $flags['post_type'] ?? 'post', 'post' ); |
| 1556 |
// Attachments are ALWAYS stored with post_status='inherit', never |
| 1557 |
// 'publish'. Defaulting to 'publish' (correct for posts/pages) |
| 1558 |
// silently returns zero media for `post list --post_type=attachment`, |
| 1559 |
// which made the agent report "no media files" on sites that have |
| 1560 |
// plenty. Mirror WP-CLI: when no --post_status is given, attachments |
| 1561 |
// default to 'inherit'; everything else keeps 'publish'. An explicit |
| 1562 |
// `--post_status=any` still overrides either way. |
| 1563 |
$default_status = 'attachment' === $post_type ? 'inherit' : 'publish'; |
| 1564 |
list( $per_page, $offset ) = $this->resolve_page_window( $flags, 'posts_per_page', 20 ); |
| 1565 |
$all_rows = ( -1 === $per_page ); |
| 1566 |
$query_args = array( |
| 1567 |
'post_type' => $post_type, |
| 1568 |
'post_status' => $this->normalize_post_status( $flags['post_status'] ?? $default_status ), |
| 1569 |
'posts_per_page' => $all_rows ? -1 : $per_page, |
| 1570 |
'offset' => $all_rows ? 0 : $offset, |
| 1571 |
'orderby' => $flags['orderby'] ?? 'date', |
| 1572 |
'order' => strtoupper( Utils::to_str( $flags['order'] ?? 'DESC', 'DESC' ) ), |
| 1573 |
); |
| 1574 |
if ( ! empty( $flags['s'] ) ) { |
| 1575 |
$query_args['s'] = Utils::to_str( $flags['s'] ); |
| 1576 |
} |
| 1577 |
|
| 1578 |
// `--format=count`: mirror WP-CLI and return only the total, computed |
| 1579 |
// from found_posts so it is accurate regardless of page size. |
| 1580 |
if ( $this->wants_count( $flags ) ) { |
| 1581 |
$count_query = new \WP_Query( |
| 1582 |
array_merge( |
| 1583 |
$query_args, |
| 1584 |
array( |
| 1585 |
'fields' => 'ids', |
| 1586 |
'posts_per_page' => 1, |
| 1587 |
'offset' => 0, |
| 1588 |
) |
| 1589 |
) |
| 1590 |
); |
| 1591 |
return Response::success( (int) $count_query->found_posts ); |
| 1592 |
} |
| 1593 |
|
| 1594 |
// `--format=ids`: flat list of post IDs for chaining into bulk ops. |
| 1595 |
if ( $this->wants_ids( $flags ) ) { |
| 1596 |
$id_query = new \WP_Query( array_merge( $query_args, array( 'fields' => 'ids' ) ) ); |
| 1597 |
$ids = array_map( static fn ( $p ) => is_scalar( $p ) ? (int) $p : 0, $id_query->posts ); |
| 1598 |
return $this->paginated_response( |
| 1599 |
$ids, |
| 1600 |
(int) $id_query->found_posts, |
| 1601 |
$all_rows ? max( 1, (int) $id_query->found_posts ) : $per_page, |
| 1602 |
$all_rows ? 0 : $offset |
| 1603 |
); |
| 1604 |
} |
| 1605 |
|
| 1606 |
$fields = $this->resolve_fields_flag( $flags, array( 'ID', 'post_title', 'post_status', 'post_date', 'post_type' ) ); |
| 1607 |
|
| 1608 |
$query = new \WP_Query( $query_args ); |
| 1609 |
$out = array(); |
| 1610 |
foreach ( $query->posts as $post ) { |
| 1611 |
if ( ! $post instanceof \WP_Post ) { |
| 1612 |
continue; |
| 1613 |
} |
| 1614 |
$out[] = $this->post_to_array( $post, $fields ); |
| 1615 |
} |
| 1616 |
$total = (int) $query->found_posts; |
| 1617 |
return $this->paginated_response( $out, $total, $all_rows ? max( 1, $total ) : $per_page, $all_rows ? 0 : $offset ); |
| 1618 |
} |
| 1619 |
|
| 1620 |
/** |
| 1621 |
* Handle "post get". |
| 1622 |
* |
| 1623 |
* @param string[] $positional Remaining positional args. |
| 1624 |
* @param array<string,mixed> $flags Parsed flags. |
| 1625 |
* @return array<string,mixed> |
| 1626 |
*/ |
| 1627 |
private function handle_post_get( array $positional, array $flags ) { |
| 1628 |
$post_id = isset( $positional[0] ) ? (int) $positional[0] : 0; |
| 1629 |
if ( $post_id <= 0 ) { |
| 1630 |
return Response::error( 'Usage: post get <ID>' ); |
| 1631 |
} |
| 1632 |
$post = get_post( $post_id ); |
| 1633 |
if ( ! $post ) { |
| 1634 |
return Response::error( sprintf( 'Post %d not found.', $post_id ) ); |
| 1635 |
} |
| 1636 |
$fields = $this->resolve_fields_flag( $flags, null ); |
| 1637 |
return Response::success( $this->post_to_array( $post, $fields ) ); |
| 1638 |
} |
| 1639 |
|
| 1640 |
/** |
| 1641 |
* Handle "post create". |
| 1642 |
* |
| 1643 |
* @param array<string,mixed> $flags Parsed flags. |
| 1644 |
* @return array<string,mixed> |
| 1645 |
*/ |
| 1646 |
private function handle_post_create( array $flags ) { |
| 1647 |
$data = $this->flags_to_post_data( $flags ); |
| 1648 |
if ( isset( $data['post_type'] ) && $this->is_managed_post_type( $data['post_type'] ) ) { |
| 1649 |
return $this->managed_post_type_error( $data['post_type'] ); |
| 1650 |
} |
| 1651 |
// wp_insert_post() runs wp_unslash() on the data — without wp_slash() |
| 1652 |
// a literal backslash in a title/excerpt ("C:\new deals") is eaten. |
| 1653 |
// The comment handlers on the identical contract already slash. |
| 1654 |
$result = wp_insert_post( wp_slash( $data ), true ); |
| 1655 |
if ( is_wp_error( $result ) ) { |
| 1656 |
return Response::error( $result->get_error_message() ); |
| 1657 |
} |
| 1658 |
return Response::success( array( 'ID' => (int) $result ) ); |
| 1659 |
} |
| 1660 |
|
| 1661 |
/** |
| 1662 |
* Handle "post update". |
| 1663 |
* |
| 1664 |
* @param string[] $positional Remaining positional args. |
| 1665 |
* @param array<string,mixed> $flags Parsed flags. |
| 1666 |
* @return array<string,mixed> |
| 1667 |
*/ |
| 1668 |
private function handle_post_update( array $positional, array $flags ) { |
| 1669 |
$post_id = isset( $positional[0] ) ? (int) $positional[0] : 0; |
| 1670 |
if ( $post_id <= 0 ) { |
| 1671 |
return Response::error( 'Usage: post update <ID> --field=value ...' ); |
| 1672 |
} |
| 1673 |
// Site-infrastructure posts are off-limits for the GENERIC post |
| 1674 |
// primitive in BOTH directions: retyping a managed post detaches it |
| 1675 |
// (a wp_template_part turned "post" drops the site chrome), and any |
| 1676 |
// sibling-field write on one degrades it (drafting a wp_global_styles |
| 1677 |
// post pulls the design tokens off the front end) — the exact outcome |
| 1678 |
// the --post_content guard already exists to prevent, one field over. |
| 1679 |
$target = get_post( $post_id ); |
| 1680 |
if ( $target && $this->is_managed_post_type( (string) $target->post_type ) ) { |
| 1681 |
return $this->managed_post_type_error( (string) $target->post_type ); |
| 1682 |
} |
| 1683 |
$data = $this->flags_to_post_data( $flags ); |
| 1684 |
if ( isset( $data['post_type'] ) && $this->is_managed_post_type( $data['post_type'] ) ) { |
| 1685 |
return $this->managed_post_type_error( $data['post_type'] ); |
| 1686 |
} |
| 1687 |
$data['ID'] = $post_id; |
| 1688 |
// wp_update_post() runs wp_unslash() on the data — see handle_post_create. |
| 1689 |
$result = wp_update_post( wp_slash( $data ), true ); |
| 1690 |
if ( is_wp_error( $result ) ) { |
| 1691 |
return Response::error( $result->get_error_message() ); |
| 1692 |
} |
| 1693 |
return Response::success( array( 'ID' => (int) $result ) ); |
| 1694 |
} |
| 1695 |
|
| 1696 |
/** |
| 1697 |
* Post types the generic post primitives must not touch — WordPress site |
| 1698 |
* infrastructure owned by dedicated tools (template editing, navigation, |
| 1699 |
* global styles, the font library). |
| 1700 |
* |
| 1701 |
* @param string $post_type Post type slug. |
| 1702 |
* @return bool |
| 1703 |
*/ |
| 1704 |
private function is_managed_post_type( string $post_type ): bool { |
| 1705 |
return in_array( |
| 1706 |
$post_type, |
| 1707 |
array( 'wp_global_styles', 'wp_template', 'wp_template_part', 'wp_navigation', 'wp_font_family', 'wp_font_face' ), |
| 1708 |
true |
| 1709 |
); |
| 1710 |
} |
| 1711 |
|
| 1712 |
/** |
| 1713 |
* The refusal for a managed post type, pointing at the owning tools. |
| 1714 |
* |
| 1715 |
* @param string $post_type Post type slug. |
| 1716 |
* @return array<string,mixed> |
| 1717 |
*/ |
| 1718 |
private function managed_post_type_error( string $post_type ): array { |
| 1719 |
return Response::error( |
| 1720 |
sprintf( |
| 1721 |
'Refused: `%s` posts are WordPress site infrastructure (templates, navigation, global styles, fonts). Generic post commands cannot create, retype, restate or delete them. Use the dedicated theme/navigation/style tools instead.', |
| 1722 |
$post_type |
| 1723 |
) |
| 1724 |
); |
| 1725 |
} |
| 1726 |
|
| 1727 |
/** |
| 1728 |
* Handle "post delete <ID> [<ID2> …] [--force]". |
| 1729 |
* |
| 1730 |
* Mirrors WP-CLI's native multi-id syntax. `wp post delete 12 34 56` |
| 1731 |
* processes all three ids. The earlier single-id code dropped everything |
| 1732 |
* after positional[0]. It reported success on the partial outcome. The LLM |
| 1733 |
* could not know it deleted too few posts. |
| 1734 |
* |
| 1735 |
* Partial-success semantics: |
| 1736 |
* - At least one delete works → `success: true`. The result holds |
| 1737 |
* `deleted` (the list of ids that landed) and `failed` (a reason per |
| 1738 |
* id). The caller phrases the response on the real counts. |
| 1739 |
* - No delete works (every id is missing or a hook refused it) → |
| 1740 |
* `Response::error`. So the caller does not claim success on a no-op |
| 1741 |
* turn. |
| 1742 |
* |
| 1743 |
* @param string[] $positional Remaining positional args (one or more post IDs). |
| 1744 |
* @param array<string,mixed> $flags Parsed flags (`--force` recognised). |
| 1745 |
* @return array<string,mixed> |
| 1746 |
*/ |
| 1747 |
private function handle_post_delete( array $positional, array $flags ): array { |
| 1748 |
if ( empty( $positional ) ) { |
| 1749 |
return Response::error( 'Usage: post delete <ID> [<ID2> …] [--force]' ); |
| 1750 |
} |
| 1751 |
$force = ! empty( $flags['force'] ); |
| 1752 |
|
| 1753 |
$deleted = array(); |
| 1754 |
$failed = array(); |
| 1755 |
|
| 1756 |
foreach ( $positional as $arg ) { |
| 1757 |
$post_id = (int) $arg; |
| 1758 |
if ( $post_id <= 0 ) { |
| 1759 |
$failed[] = array( |
| 1760 |
'id' => (string) $arg, |
| 1761 |
'reason' => 'not a positive integer', |
| 1762 |
); |
| 1763 |
continue; |
| 1764 |
} |
| 1765 |
// Same infrastructure guard as post update — deleting a |
| 1766 |
// wp_template_part / wp_navigation / wp_global_styles post through |
| 1767 |
// the generic primitive detaches site chrome or design tokens. |
| 1768 |
$target = get_post( $post_id ); |
| 1769 |
if ( $target && $this->is_managed_post_type( (string) $target->post_type ) ) { |
| 1770 |
$failed[] = array( |
| 1771 |
'id' => $post_id, |
| 1772 |
'reason' => "post type `{$target->post_type}` is site infrastructure, use the dedicated theme/navigation/style tools", |
| 1773 |
); |
| 1774 |
continue; |
| 1775 |
} |
| 1776 |
$result = wp_delete_post( $post_id, $force ); |
| 1777 |
if ( $result ) { |
| 1778 |
$deleted[] = $post_id; |
| 1779 |
} else { |
| 1780 |
$failed[] = array( |
| 1781 |
'id' => $post_id, |
| 1782 |
'reason' => 'wp_delete_post returned false (post not found, already trashed when --force omitted, or hook refused)', |
| 1783 |
); |
| 1784 |
} |
| 1785 |
} |
| 1786 |
|
| 1787 |
// All-fail → error, so the caller doesn't claim success on zero deletes. |
| 1788 |
// Mirrors the same truth-telling rule core__bulk_run_wp_cli enforces: |
| 1789 |
// if NOTHING landed, don't mark dependent todos done. |
| 1790 |
if ( empty( $deleted ) ) { |
| 1791 |
$ids = array_map( static fn( $f ) => (string) $f['id'], $failed ); |
| 1792 |
$details = array_map( static fn( $f ) => $f['id'] . ' (' . $f['reason'] . ')', $failed ); |
| 1793 |
return Response::error( |
| 1794 |
sprintf( |
| 1795 |
'No posts deleted. Attempted IDs: %s. Failures: %s.', |
| 1796 |
implode( ', ', $ids ), |
| 1797 |
implode( '; ', $details ) |
| 1798 |
) |
| 1799 |
); |
| 1800 |
} |
| 1801 |
|
| 1802 |
return Response::success( |
| 1803 |
array( |
| 1804 |
'deleted' => $deleted, |
| 1805 |
'failed' => $failed, |
| 1806 |
'forced' => $force, |
| 1807 |
) |
| 1808 |
); |
| 1809 |
} |
| 1810 |
|
| 1811 |
// ─── Comment handlers ─────────────────────────────────────────────────────── |
| 1812 |
|
| 1813 |
/** |
| 1814 |
* Handle "comment list". |
| 1815 |
* |
| 1816 |
* @param array<string,mixed> $flags Parsed flags. |
| 1817 |
* @return array<string,mixed> |
| 1818 |
*/ |
| 1819 |
private function handle_comment_list( array $flags ): array { |
| 1820 |
list( $per_page, $offset ) = $this->resolve_page_window( $flags, 'number', 20 ); |
| 1821 |
$all_rows = ( -1 === $per_page ); |
| 1822 |
$query_args = array( |
| 1823 |
// WP_Comment_Query's 'all' covers approved + pending; spam/trash |
| 1824 |
// rows only appear when requested explicitly via --status. |
| 1825 |
'status' => Utils::to_str( $flags['status'] ?? 'all', 'all' ), |
| 1826 |
'number' => $all_rows ? 0 : $per_page, |
| 1827 |
'offset' => $all_rows ? 0 : $offset, |
| 1828 |
'orderby' => $flags['orderby'] ?? 'comment_date_gmt', |
| 1829 |
'order' => strtoupper( Utils::to_str( $flags['order'] ?? 'DESC', 'DESC' ) ), |
| 1830 |
); |
| 1831 |
// WP-CLI uses --comment_post_ID; accept --post_id as the friendlier alias. |
| 1832 |
$post_id = Utils::to_int( $flags['comment_post_ID'] ?? $flags['post_id'] ?? 0 ); |
| 1833 |
if ( $post_id > 0 ) { |
| 1834 |
$query_args['post_id'] = $post_id; |
| 1835 |
} |
| 1836 |
if ( ! empty( $flags['search'] ) ) { |
| 1837 |
$query_args['search'] = Utils::to_str( $flags['search'] ); |
| 1838 |
} |
| 1839 |
if ( ! empty( $flags['type'] ) ) { |
| 1840 |
$query_args['type'] = Utils::to_str( $flags['type'] ); |
| 1841 |
} |
| 1842 |
|
| 1843 |
$count_args = array_merge( |
| 1844 |
$query_args, |
| 1845 |
array( |
| 1846 |
'count' => true, |
| 1847 |
'number' => 0, |
| 1848 |
'offset' => 0, |
| 1849 |
) |
| 1850 |
); |
| 1851 |
|
| 1852 |
// `--format=count`: total matching comments, no rows fetched. |
| 1853 |
if ( $this->wants_count( $flags ) ) { |
| 1854 |
return Response::success( (int) ( new \WP_Comment_Query() )->query( $count_args ) ); |
| 1855 |
} |
| 1856 |
|
| 1857 |
$total = (int) ( new \WP_Comment_Query() )->query( $count_args ); |
| 1858 |
|
| 1859 |
// `--format=ids`: flat list of comment IDs for chaining into bulk ops. |
| 1860 |
if ( $this->wants_ids( $flags ) ) { |
| 1861 |
$ids = (array) ( new \WP_Comment_Query() )->query( array_merge( $query_args, array( 'fields' => 'ids' ) ) ); |
| 1862 |
return $this->paginated_response( |
| 1863 |
array_map( static fn ( $id ) => is_scalar( $id ) ? (int) $id : 0, $ids ), |
| 1864 |
$total, |
| 1865 |
$all_rows ? max( 1, $total ) : $per_page, |
| 1866 |
$all_rows ? 0 : $offset |
| 1867 |
); |
| 1868 |
} |
| 1869 |
|
| 1870 |
$fields = $this->resolve_fields_flag( |
| 1871 |
$flags, |
| 1872 |
array( 'comment_ID', 'comment_post_ID', 'comment_author', 'comment_author_email', 'comment_date', 'status' ) |
| 1873 |
); |
| 1874 |
$comments = (array) ( new \WP_Comment_Query() )->query( $query_args ); |
| 1875 |
$out = array(); |
| 1876 |
foreach ( $comments as $comment ) { |
| 1877 |
if ( ! $comment instanceof \WP_Comment ) { |
| 1878 |
continue; |
| 1879 |
} |
| 1880 |
$out[] = $this->comment_to_array( $comment, $fields ); |
| 1881 |
} |
| 1882 |
return $this->paginated_response( $out, $total, $all_rows ? max( 1, $total ) : $per_page, $all_rows ? 0 : $offset ); |
| 1883 |
} |
| 1884 |
|
| 1885 |
/** |
| 1886 |
* Handle "comment get". |
| 1887 |
* |
| 1888 |
* @param string[] $positional Remaining positional args. |
| 1889 |
* @param array<string,mixed> $flags Parsed flags. |
| 1890 |
* @return array<string,mixed> |
| 1891 |
*/ |
| 1892 |
private function handle_comment_get( array $positional, array $flags ) { |
| 1893 |
$comment_id = isset( $positional[0] ) ? (int) $positional[0] : 0; |
| 1894 |
if ( $comment_id <= 0 ) { |
| 1895 |
return Response::error( 'Usage: comment get <ID>' ); |
| 1896 |
} |
| 1897 |
$comment = get_comment( $comment_id ); |
| 1898 |
if ( ! $comment ) { |
| 1899 |
return Response::error( sprintf( 'Comment %d not found.', $comment_id ) ); |
| 1900 |
} |
| 1901 |
$fields = $this->resolve_fields_flag( $flags, null ); |
| 1902 |
return Response::success( $this->comment_to_array( $comment, $fields ) ); |
| 1903 |
} |
| 1904 |
|
| 1905 |
/** |
| 1906 |
* Handle "comment count [<post-ID>]". |
| 1907 |
* |
| 1908 |
* @param string[] $positional Remaining positional args. |
| 1909 |
* @return array<string,mixed> |
| 1910 |
*/ |
| 1911 |
private function handle_comment_count( array $positional ): array { |
| 1912 |
$post_id = isset( $positional[0] ) ? (int) $positional[0] : 0; |
| 1913 |
$counts = wp_count_comments( $post_id ); |
| 1914 |
return Response::success( (array) $counts ); |
| 1915 |
} |
| 1916 |
|
| 1917 |
/** |
| 1918 |
* Handle "comment exists <ID>". |
| 1919 |
* |
| 1920 |
* @param string[] $positional Remaining positional args. |
| 1921 |
* @return array<string,mixed> |
| 1922 |
*/ |
| 1923 |
private function handle_comment_exists( array $positional ): array { |
| 1924 |
$comment_id = isset( $positional[0] ) ? (int) $positional[0] : 0; |
| 1925 |
if ( $comment_id <= 0 ) { |
| 1926 |
return Response::error( 'Usage: comment exists <ID>' ); |
| 1927 |
} |
| 1928 |
return Response::success( array( 'exists' => (bool) get_comment( $comment_id ) ) ); |
| 1929 |
} |
| 1930 |
|
| 1931 |
/** |
| 1932 |
* Handle "comment status <ID>". |
| 1933 |
* |
| 1934 |
* @param string[] $positional Remaining positional args. |
| 1935 |
* @return array<string,mixed> |
| 1936 |
*/ |
| 1937 |
private function handle_comment_status( array $positional ): array { |
| 1938 |
$comment_id = isset( $positional[0] ) ? (int) $positional[0] : 0; |
| 1939 |
if ( $comment_id <= 0 ) { |
| 1940 |
return Response::error( 'Usage: comment status <ID>' ); |
| 1941 |
} |
| 1942 |
$status = wp_get_comment_status( $comment_id ); |
| 1943 |
if ( false === $status ) { |
| 1944 |
return Response::error( sprintf( 'Comment %d not found.', $comment_id ) ); |
| 1945 |
} |
| 1946 |
return Response::success( array( 'status' => $status ) ); |
| 1947 |
} |
| 1948 |
|
| 1949 |
/** |
| 1950 |
* Handle "comment create". |
| 1951 |
* |
| 1952 |
* @param array<string,mixed> $flags Parsed flags. |
| 1953 |
* @return array<string,mixed> |
| 1954 |
*/ |
| 1955 |
private function handle_comment_create( array $flags ) { |
| 1956 |
$data = $this->flags_to_comment_data( $flags ); |
| 1957 |
|
| 1958 |
$post_id = (int) ( $data['comment_post_ID'] ?? 0 ); |
| 1959 |
if ( $post_id <= 0 ) { |
| 1960 |
return Response::error( 'Usage: comment create --comment_post_ID=<post-ID> --comment_content="…" [--comment_author=…] [--comment_author_email=…] [--comment_approved=0|1]' ); |
| 1961 |
} |
| 1962 |
if ( ! get_post( $post_id ) ) { |
| 1963 |
return Response::error( sprintf( 'Post %d not found — cannot attach a comment to it.', $post_id ) ); |
| 1964 |
} |
| 1965 |
|
| 1966 |
// Mirror WP-CLI: comments created by the tool are approved unless told otherwise. |
| 1967 |
if ( ! array_key_exists( 'comment_approved', $data ) ) { |
| 1968 |
$data['comment_approved'] = 1; |
| 1969 |
} |
| 1970 |
|
| 1971 |
$comment_id = wp_insert_comment( wp_slash( $data ) ); |
| 1972 |
if ( ! $comment_id ) { |
| 1973 |
return Response::error( 'wp_insert_comment failed (hook refused or invalid data).' ); |
| 1974 |
} |
| 1975 |
return Response::success( array( 'comment_ID' => (int) $comment_id ) ); |
| 1976 |
} |
| 1977 |
|
| 1978 |
/** |
| 1979 |
* Handle "comment update". |
| 1980 |
* |
| 1981 |
* @param string[] $positional Remaining positional args. |
| 1982 |
* @param array<string,mixed> $flags Parsed flags. |
| 1983 |
* @return array<string,mixed> |
| 1984 |
*/ |
| 1985 |
private function handle_comment_update( array $positional, array $flags ) { |
| 1986 |
$comment_id = isset( $positional[0] ) ? (int) $positional[0] : 0; |
| 1987 |
if ( $comment_id <= 0 ) { |
| 1988 |
return Response::error( 'Usage: comment update <ID> --field=value ...' ); |
| 1989 |
} |
| 1990 |
if ( ! get_comment( $comment_id ) ) { |
| 1991 |
return Response::error( sprintf( 'Comment %d not found.', $comment_id ) ); |
| 1992 |
} |
| 1993 |
$data = $this->flags_to_comment_data( $flags ); |
| 1994 |
if ( empty( $data ) ) { |
| 1995 |
return Response::error( 'No updatable fields supplied. Pass flags like --comment_content="…".' ); |
| 1996 |
} |
| 1997 |
$data['comment_ID'] = $comment_id; |
| 1998 |
|
| 1999 |
$result = wp_update_comment( wp_slash( $data ), true ); |
| 2000 |
if ( is_wp_error( $result ) ) { |
| 2001 |
return Response::error( $result->get_error_message() ); |
| 2002 |
} |
| 2003 |
return Response::success( |
| 2004 |
array( |
| 2005 |
'comment_ID' => $comment_id, |
| 2006 |
'updated' => (bool) $result, |
| 2007 |
) |
| 2008 |
); |
| 2009 |
} |
| 2010 |
|
| 2011 |
/** |
| 2012 |
* Handle "comment delete <ID> [<ID2> …] [--force]". |
| 2013 |
* |
| 2014 |
* Same partial-success semantics as `post delete`: report what landed |
| 2015 |
* and what failed; all-fail → error so the caller doesn't claim success |
| 2016 |
* on a no-op turn. Without --force the comment is trashed (WP default); |
| 2017 |
* with --force it is permanently deleted. |
| 2018 |
* |
| 2019 |
* @param string[] $positional Remaining positional args (one or more comment IDs). |
| 2020 |
* @param array<string,mixed> $flags Parsed flags (`--force` recognised). |
| 2021 |
* @return array<string,mixed> |
| 2022 |
*/ |
| 2023 |
private function handle_comment_delete( array $positional, array $flags ): array { |
| 2024 |
if ( empty( $positional ) ) { |
| 2025 |
return Response::error( 'Usage: comment delete <ID> [<ID2> …] [--force]' ); |
| 2026 |
} |
| 2027 |
$force = ! empty( $flags['force'] ); |
| 2028 |
|
| 2029 |
$deleted = array(); |
| 2030 |
$failed = array(); |
| 2031 |
|
| 2032 |
foreach ( $positional as $arg ) { |
| 2033 |
$comment_id = (int) $arg; |
| 2034 |
if ( $comment_id <= 0 ) { |
| 2035 |
$failed[] = array( |
| 2036 |
'id' => (string) $arg, |
| 2037 |
'reason' => 'not a positive integer', |
| 2038 |
); |
| 2039 |
continue; |
| 2040 |
} |
| 2041 |
$result = wp_delete_comment( $comment_id, $force ); |
| 2042 |
if ( $result ) { |
| 2043 |
$deleted[] = $comment_id; |
| 2044 |
} else { |
| 2045 |
$failed[] = array( |
| 2046 |
'id' => $comment_id, |
| 2047 |
'reason' => 'wp_delete_comment returned false (comment not found, already trashed when --force omitted, or hook refused)', |
| 2048 |
); |
| 2049 |
} |
| 2050 |
} |
| 2051 |
|
| 2052 |
if ( empty( $deleted ) ) { |
| 2053 |
$ids = array_map( static fn( $f ) => (string) $f['id'], $failed ); |
| 2054 |
$details = array_map( static fn( $f ) => $f['id'] . ' (' . $f['reason'] . ')', $failed ); |
| 2055 |
return Response::error( |
| 2056 |
sprintf( |
| 2057 |
'No comments deleted. Attempted IDs: %s. Failures: %s.', |
| 2058 |
implode( ', ', $ids ), |
| 2059 |
implode( '; ', $details ) |
| 2060 |
) |
| 2061 |
); |
| 2062 |
} |
| 2063 |
|
| 2064 |
return Response::success( |
| 2065 |
array( |
| 2066 |
'deleted' => $deleted, |
| 2067 |
'failed' => $failed, |
| 2068 |
'forced' => $force, |
| 2069 |
) |
| 2070 |
); |
| 2071 |
} |
| 2072 |
|
| 2073 |
/** |
| 2074 |
* Handle "comment approve|unapprove|spam|unspam|trash|untrash <ID> [<ID2> …]". |
| 2075 |
* |
| 2076 |
* Same partial-success semantics as `comment delete`. |
| 2077 |
* |
| 2078 |
* @param string $verb Moderation verb. |
| 2079 |
* @param string[] $positional One or more comment IDs. |
| 2080 |
* @return array<string,mixed> |
| 2081 |
*/ |
| 2082 |
private function handle_comment_set_status( string $verb, array $positional ): array { |
| 2083 |
if ( empty( $positional ) ) { |
| 2084 |
return Response::error( sprintf( 'Usage: comment %s <ID> [<ID2> …]', $verb ) ); |
| 2085 |
} |
| 2086 |
|
| 2087 |
$changed = array(); |
| 2088 |
$failed = array(); |
| 2089 |
|
| 2090 |
foreach ( $positional as $arg ) { |
| 2091 |
$comment_id = (int) $arg; |
| 2092 |
if ( $comment_id <= 0 ) { |
| 2093 |
$failed[] = array( |
| 2094 |
'id' => (string) $arg, |
| 2095 |
'reason' => 'not a positive integer', |
| 2096 |
); |
| 2097 |
continue; |
| 2098 |
} |
| 2099 |
if ( ! get_comment( $comment_id ) ) { |
| 2100 |
$failed[] = array( |
| 2101 |
'id' => $comment_id, |
| 2102 |
'reason' => 'comment not found', |
| 2103 |
); |
| 2104 |
continue; |
| 2105 |
} |
| 2106 |
|
| 2107 |
switch ( $verb ) { |
| 2108 |
case 'approve': |
| 2109 |
$result = wp_set_comment_status( $comment_id, 'approve', true ); |
| 2110 |
break; |
| 2111 |
case 'unapprove': |
| 2112 |
$result = wp_set_comment_status( $comment_id, 'hold', true ); |
| 2113 |
break; |
| 2114 |
case 'spam': |
| 2115 |
$result = wp_spam_comment( $comment_id ); |
| 2116 |
break; |
| 2117 |
case 'unspam': |
| 2118 |
$result = wp_unspam_comment( $comment_id ); |
| 2119 |
break; |
| 2120 |
case 'trash': |
| 2121 |
$result = wp_trash_comment( $comment_id ); |
| 2122 |
break; |
| 2123 |
case 'untrash': |
| 2124 |
$result = wp_untrash_comment( $comment_id ); |
| 2125 |
break; |
| 2126 |
default: |
| 2127 |
return Response::error( sprintf( 'Unsupported comment moderation verb: "%s".', $verb ) ); |
| 2128 |
} |
| 2129 |
|
| 2130 |
if ( is_wp_error( $result ) ) { |
| 2131 |
$failed[] = array( |
| 2132 |
'id' => $comment_id, |
| 2133 |
'reason' => $result->get_error_message(), |
| 2134 |
); |
| 2135 |
} elseif ( $result ) { |
| 2136 |
$changed[] = $comment_id; |
| 2137 |
} else { |
| 2138 |
$failed[] = array( |
| 2139 |
'id' => $comment_id, |
| 2140 |
'reason' => 'status change refused (already in target status, or hook refused)', |
| 2141 |
); |
| 2142 |
} |
| 2143 |
} |
| 2144 |
|
| 2145 |
if ( empty( $changed ) ) { |
| 2146 |
$details = array_map( static fn( $f ) => $f['id'] . ' (' . $f['reason'] . ')', $failed ); |
| 2147 |
return Response::error( |
| 2148 |
sprintf( |
| 2149 |
'No comments %sd. Failures: %s.', |
| 2150 |
$verb, |
| 2151 |
implode( '; ', $details ) |
| 2152 |
) |
| 2153 |
); |
| 2154 |
} |
| 2155 |
|
| 2156 |
return Response::success( |
| 2157 |
array( |
| 2158 |
'action' => $verb, |
| 2159 |
'changed' => $changed, |
| 2160 |
'failed' => $failed, |
| 2161 |
) |
| 2162 |
); |
| 2163 |
} |
| 2164 |
|
| 2165 |
/** |
| 2166 |
* Handle "comment recount <post-ID> [<post-ID2> …]". |
| 2167 |
* |
| 2168 |
* Recalculates the cached comment_count on each post. |
| 2169 |
* |
| 2170 |
* @param string[] $positional One or more post IDs. |
| 2171 |
* @return array<string,mixed> |
| 2172 |
*/ |
| 2173 |
private function handle_comment_recount( array $positional ): array { |
| 2174 |
if ( empty( $positional ) ) { |
| 2175 |
return Response::error( 'Usage: comment recount <post-ID> [<post-ID2> …]' ); |
| 2176 |
} |
| 2177 |
|
| 2178 |
$out = array(); |
| 2179 |
foreach ( $positional as $arg ) { |
| 2180 |
$post_id = (int) $arg; |
| 2181 |
$post = $post_id > 0 ? get_post( $post_id ) : null; |
| 2182 |
if ( ! $post ) { |
| 2183 |
$out[] = array( |
| 2184 |
'post_ID' => (string) $arg, |
| 2185 |
'error' => 'post not found', |
| 2186 |
); |
| 2187 |
continue; |
| 2188 |
} |
| 2189 |
wp_update_comment_count_now( $post_id ); |
| 2190 |
$out[] = array( |
| 2191 |
'post_ID' => $post_id, |
| 2192 |
'comment_count' => (int) get_post( $post_id )->comment_count, |
| 2193 |
); |
| 2194 |
} |
| 2195 |
return Response::success( $out ); |
| 2196 |
} |
| 2197 |
|
| 2198 |
/** |
| 2199 |
* Serialise a WP_Comment to a JSON-friendly array. |
| 2200 |
* |
| 2201 |
* Includes a derived `status` (approved/unapproved/spam/trash) so the |
| 2202 |
* agent never has to decode raw comment_approved values ('0'/'1'/'spam'). |
| 2203 |
* |
| 2204 |
* @param \WP_Comment $comment Comment. |
| 2205 |
* @param string[]|null $fields Field subset; null for all. |
| 2206 |
* @return array<string,mixed> |
| 2207 |
*/ |
| 2208 |
private function comment_to_array( \WP_Comment $comment, ?array $fields ): array { |
| 2209 |
$all = array( |
| 2210 |
'comment_ID' => (int) $comment->comment_ID, |
| 2211 |
'comment_post_ID' => (int) $comment->comment_post_ID, |
| 2212 |
'comment_author' => $comment->comment_author, |
| 2213 |
'comment_author_email' => $comment->comment_author_email, |
| 2214 |
'comment_author_url' => $comment->comment_author_url, |
| 2215 |
'comment_date' => $comment->comment_date, |
| 2216 |
'comment_content' => $comment->comment_content, |
| 2217 |
'comment_type' => $comment->comment_type, |
| 2218 |
'comment_parent' => (int) $comment->comment_parent, |
| 2219 |
'comment_approved' => $comment->comment_approved, |
| 2220 |
'user_id' => (int) $comment->user_id, |
| 2221 |
'status' => wp_get_comment_status( $comment ), |
| 2222 |
); |
| 2223 |
if ( null === $fields ) { |
| 2224 |
return $all; |
| 2225 |
} |
| 2226 |
$out = array(); |
| 2227 |
foreach ( $fields as $f ) { |
| 2228 |
$out[ $f ] = $all[ $f ] ?? null; |
| 2229 |
} |
| 2230 |
return $out; |
| 2231 |
} |
| 2232 |
|
| 2233 |
/** |
| 2234 |
* Translate WP-CLI-style --flags into a wp_insert_comment/wp_update_comment array. |
| 2235 |
* |
| 2236 |
* @param array<string,mixed> $flags Parsed flags. |
| 2237 |
* @return array{comment_post_ID?:int,comment_content?:string,comment_author?:string,comment_author_email?:string,comment_author_url?:string,comment_approved?:string,comment_parent?:int,comment_type?:string,comment_date?:string,user_id?:int} |
| 2238 |
*/ |
| 2239 |
private function flags_to_comment_data( array $flags ): array { |
| 2240 |
$out = array(); |
| 2241 |
if ( array_key_exists( 'comment_post_ID', $flags ) ) { |
| 2242 |
$out['comment_post_ID'] = Utils::to_int( $flags['comment_post_ID'] ); |
| 2243 |
} |
| 2244 |
if ( array_key_exists( 'comment_content', $flags ) ) { |
| 2245 |
$out['comment_content'] = Utils::to_str( $flags['comment_content'] ); |
| 2246 |
} |
| 2247 |
if ( array_key_exists( 'comment_author', $flags ) ) { |
| 2248 |
$out['comment_author'] = Utils::to_str( $flags['comment_author'] ); |
| 2249 |
} |
| 2250 |
if ( array_key_exists( 'comment_author_email', $flags ) ) { |
| 2251 |
$out['comment_author_email'] = Utils::to_str( $flags['comment_author_email'] ); |
| 2252 |
} |
| 2253 |
if ( array_key_exists( 'comment_author_url', $flags ) ) { |
| 2254 |
$out['comment_author_url'] = Utils::to_str( $flags['comment_author_url'] ); |
| 2255 |
} |
| 2256 |
if ( array_key_exists( 'comment_approved', $flags ) ) { |
| 2257 |
$out['comment_approved'] = Utils::to_str( $flags['comment_approved'] ); |
| 2258 |
} |
| 2259 |
if ( array_key_exists( 'comment_parent', $flags ) ) { |
| 2260 |
$out['comment_parent'] = Utils::to_int( $flags['comment_parent'] ); |
| 2261 |
} |
| 2262 |
if ( array_key_exists( 'comment_type', $flags ) ) { |
| 2263 |
$out['comment_type'] = Utils::to_str( $flags['comment_type'] ); |
| 2264 |
} |
| 2265 |
if ( array_key_exists( 'comment_date', $flags ) ) { |
| 2266 |
$out['comment_date'] = Utils::to_str( $flags['comment_date'] ); |
| 2267 |
} |
| 2268 |
if ( array_key_exists( 'user_id', $flags ) ) { |
| 2269 |
$out['user_id'] = Utils::to_int( $flags['user_id'] ); |
| 2270 |
} |
| 2271 |
return $out; |
| 2272 |
} |
| 2273 |
|
| 2274 |
// ─── Meta (post + user + comment) ─────────────────────────────────────────── |
| 2275 |
|
| 2276 |
/** |
| 2277 |
* Route post-meta / user-meta / comment-meta subcommands. |
| 2278 |
* |
| 2279 |
* @param string $object "post", "user" or "comment". |
| 2280 |
* @param string[] $positional Remaining positional args after "<object> meta". |
| 2281 |
* @param array<string,mixed> $flags Parsed flags. |
| 2282 |
* @return array<string,mixed> |
| 2283 |
*/ |
| 2284 |
private function route_meta( string $object, array $positional, array $flags ): array { |
| 2285 |
unset( $flags ); |
| 2286 |
$verb = strtolower( $positional[0] ?? '' ); |
| 2287 |
$id = isset( $positional[1] ) ? (int) $positional[1] : 0; |
| 2288 |
|
| 2289 |
// Writes are blocked in verify_command_security(); only reads reach here. |
| 2290 |
$get_fns = array( |
| 2291 |
'user' => 'get_user_meta', |
| 2292 |
'comment' => 'get_comment_meta', |
| 2293 |
); |
| 2294 |
$get_fn = $get_fns[ $object ] ?? 'get_post_meta'; |
| 2295 |
|
| 2296 |
if ( 'get' === $verb ) { |
| 2297 |
$key = $positional[2] ?? ''; |
| 2298 |
if ( $id <= 0 || '' === $key ) { |
| 2299 |
return Response::error( sprintf( 'Usage: %s meta get <id> <key>', $object ) ); |
| 2300 |
} |
| 2301 |
return Response::success( array( 'value' => $get_fn( $id, $key, true ) ) ); |
| 2302 |
} |
| 2303 |
|
| 2304 |
if ( 'list' === $verb ) { |
| 2305 |
if ( $id <= 0 ) { |
| 2306 |
return Response::error( sprintf( 'Usage: %s meta list <id>', $object ) ); |
| 2307 |
} |
| 2308 |
// Return all meta as { key: value } — single-element arrays are |
| 2309 |
// unwrapped to the scalar so the LLM sees a clean key/value map. |
| 2310 |
$all = $get_fn( $id ); |
| 2311 |
if ( ! is_array( $all ) ) { |
| 2312 |
return Response::success( array() ); |
| 2313 |
} |
| 2314 |
$out = array(); |
| 2315 |
foreach ( $all as $k => $v ) { |
| 2316 |
if ( is_array( $v ) && 1 === count( $v ) ) { |
| 2317 |
$first = reset( $v ); |
| 2318 |
$out[ $k ] = is_string( $first ) ? maybe_unserialize( $first ) : $first; |
| 2319 |
} else { |
| 2320 |
$out[ $k ] = $v; |
| 2321 |
} |
| 2322 |
} |
| 2323 |
return Response::success( $out ); |
| 2324 |
} |
| 2325 |
|
| 2326 |
// Anything else (add/update/set/delete/patch) reaches this only if the |
| 2327 |
// security check failed to fire — defensive fallback with a clear |
| 2328 |
// "not supported" rather than the misleading "writes are blocked". |
| 2329 |
if ( in_array( $verb, array( 'add', 'update', 'set', 'delete', 'patch' ), true ) ) { |
| 2330 |
return Response::error( sprintf( 'Security policy: %s meta writes are blocked. Use a dedicated ability for the specific meta key.', $object ) ); |
| 2331 |
} |
| 2332 |
return Response::error( sprintf( 'Unsupported %s meta verb: "%s". Supported: get, list.', $object, $verb ) ); |
| 2333 |
} |
| 2334 |
|
| 2335 |
// ─── User handlers ────────────────────────────────────────────────────────── |
| 2336 |
|
| 2337 |
/** |
| 2338 |
* Handle "user list". |
| 2339 |
* |
| 2340 |
* @param array<string,mixed> $flags Parsed flags. |
| 2341 |
* @return array<string,mixed> |
| 2342 |
*/ |
| 2343 |
private function handle_user_list( array $flags ): array { |
| 2344 |
list( $per_page, $offset ) = $this->resolve_page_window( $flags, 'number', 50 ); |
| 2345 |
$all_rows = ( -1 === $per_page ); |
| 2346 |
$query_args = array( |
| 2347 |
'number' => $all_rows ? -1 : $per_page, |
| 2348 |
'offset' => $all_rows ? 0 : $offset, |
| 2349 |
'count_total' => true, |
| 2350 |
); |
| 2351 |
if ( ! empty( $flags['role'] ) ) { |
| 2352 |
$query_args['role'] = Utils::to_str( $flags['role'] ); |
| 2353 |
} |
| 2354 |
if ( ! empty( $flags['search'] ) ) { |
| 2355 |
$query_args['search'] = '*' . Utils::to_str( $flags['search'] ) . '*'; |
| 2356 |
} |
| 2357 |
|
| 2358 |
// `--format=count`: total matching users, no rows fetched. |
| 2359 |
if ( $this->wants_count( $flags ) ) { |
| 2360 |
$count_query = new \WP_User_Query( |
| 2361 |
array_merge( |
| 2362 |
$query_args, |
| 2363 |
array( |
| 2364 |
'number' => 1, |
| 2365 |
'offset' => 0, |
| 2366 |
'fields' => 'ID', |
| 2367 |
) |
| 2368 |
) |
| 2369 |
); |
| 2370 |
return Response::success( (int) $count_query->get_total() ); |
| 2371 |
} |
| 2372 |
|
| 2373 |
// `--format=ids`: flat list of user IDs. |
| 2374 |
if ( $this->wants_ids( $flags ) ) { |
| 2375 |
$id_query = new \WP_User_Query( array_merge( $query_args, array( 'fields' => 'ID' ) ) ); |
| 2376 |
$ids = array_map( static fn ( $u ) => is_scalar( $u ) ? (int) $u : 0, $id_query->get_results() ); |
| 2377 |
return $this->paginated_response( |
| 2378 |
$ids, |
| 2379 |
(int) $id_query->get_total(), |
| 2380 |
$all_rows ? max( 1, (int) $id_query->get_total() ) : $per_page, |
| 2381 |
$all_rows ? 0 : $offset |
| 2382 |
); |
| 2383 |
} |
| 2384 |
|
| 2385 |
$query = new \WP_User_Query( $query_args ); |
| 2386 |
$fields = $this->resolve_fields_flag( $flags, array( 'ID', 'user_login', 'user_email', 'display_name', 'roles' ) ); |
| 2387 |
|
| 2388 |
$out = array(); |
| 2389 |
foreach ( $query->get_results() as $user ) { |
| 2390 |
if ( ! $user instanceof \WP_User ) { |
| 2391 |
continue; |
| 2392 |
} |
| 2393 |
$out[] = $this->user_to_array( $user, $fields ); |
| 2394 |
} |
| 2395 |
$total = (int) $query->get_total(); |
| 2396 |
return $this->paginated_response( $out, $total, $all_rows ? max( 1, $total ) : $per_page, $all_rows ? 0 : $offset ); |
| 2397 |
} |
| 2398 |
|
| 2399 |
/** |
| 2400 |
* Handle "user get". |
| 2401 |
* |
| 2402 |
* @param string[] $positional Remaining positional args. |
| 2403 |
* @param array<string,mixed> $flags Parsed flags. |
| 2404 |
* @return array<string,mixed> |
| 2405 |
*/ |
| 2406 |
private function handle_user_get( array $positional, array $flags ) { |
| 2407 |
$ident = $positional[0] ?? ''; |
| 2408 |
if ( '' === $ident ) { |
| 2409 |
return Response::error( 'Usage: user get <id-or-login>' ); |
| 2410 |
} |
| 2411 |
$user = is_numeric( $ident ) ? get_user_by( 'id', (int) $ident ) : get_user_by( 'login', $ident ); |
| 2412 |
if ( ! $user ) { |
| 2413 |
return Response::error( sprintf( 'User "%s" not found.', $ident ) ); |
| 2414 |
} |
| 2415 |
$fields = $this->resolve_fields_flag( $flags, null ); |
| 2416 |
return Response::success( $this->user_to_array( $user, $fields ) ); |
| 2417 |
} |
| 2418 |
|
| 2419 |
/** |
| 2420 |
* Handle `user add-role|remove-role|set-role <id-or-login> <role>`. |
| 2421 |
* |
| 2422 |
* Admin/super-admin roles are refused in `verify_command_security()`; |
| 2423 |
* by the time we reach here the role is editor/author/contributor/ |
| 2424 |
* subscriber/custom-non-admin. |
| 2425 |
* |
| 2426 |
* @param string $action 'add' | 'remove' | 'set'. |
| 2427 |
* @param string[] $positional Remaining tokens — [id-or-login, role]. |
| 2428 |
* @return array<string,mixed> |
| 2429 |
*/ |
| 2430 |
private function handle_user_role_change( string $action, array $positional ): array { |
| 2431 |
$ident = $positional[0] ?? ''; |
| 2432 |
$role = strtolower( $positional[1] ?? '' ); |
| 2433 |
if ( '' === $ident || '' === $role ) { |
| 2434 |
return Response::error( sprintf( 'Usage: user %s-role <id-or-login> <role>', $action ) ); |
| 2435 |
} |
| 2436 |
// Defense in depth — the security verifier already blocks |
| 2437 |
// administrator/super-admin, but a future caller (or a verifier |
| 2438 |
// regression) shouldn't be able to elevate a user here. |
| 2439 |
if ( in_array( $role, array( 'administrator', 'super-admin' ), true ) ) { |
| 2440 |
return Response::error( sprintf( 'Security policy: role "%s" cannot be changed via this endpoint.', $role ) ); |
| 2441 |
} |
| 2442 |
$user = is_numeric( $ident ) ? get_user_by( 'id', (int) $ident ) : get_user_by( 'login', $ident ); |
| 2443 |
if ( ! $user ) { |
| 2444 |
return Response::error( sprintf( 'User "%s" not found.', $ident ) ); |
| 2445 |
} |
| 2446 |
// Validate the role exists. |
| 2447 |
if ( ! get_role( $role ) ) { |
| 2448 |
return Response::error( sprintf( 'Role "%s" is not registered. Available roles: %s.', $role, implode( ', ', array_keys( wp_roles()->roles ) ) ) ); |
| 2449 |
} |
| 2450 |
|
| 2451 |
switch ( $action ) { |
| 2452 |
case 'add': |
| 2453 |
$user->add_role( $role ); |
| 2454 |
break; |
| 2455 |
case 'remove': |
| 2456 |
$user->remove_role( $role ); |
| 2457 |
break; |
| 2458 |
case 'set': |
| 2459 |
$user->set_role( $role ); |
| 2460 |
break; |
| 2461 |
default: |
| 2462 |
return Response::error( sprintf( 'Unsupported role action: "%s".', $action ) ); |
| 2463 |
} |
| 2464 |
|
| 2465 |
// Refresh user object for the response. |
| 2466 |
$refreshed = get_user_by( 'id', $user->ID ); |
| 2467 |
return Response::success( |
| 2468 |
array( |
| 2469 |
'ID' => (int) $user->ID, |
| 2470 |
'login' => $user->user_login, |
| 2471 |
'roles' => $refreshed instanceof \WP_User ? array_values( $refreshed->roles ) : array(), |
| 2472 |
'action' => $action, |
| 2473 |
'role' => $role, |
| 2474 |
) |
| 2475 |
); |
| 2476 |
} |
| 2477 |
|
| 2478 |
/** |
| 2479 |
* Handle `user add-cap|remove-cap <id-or-login> <capability>`. |
| 2480 |
* |
| 2481 |
* Admin-class capabilities are refused in `verify_command_security()`. |
| 2482 |
* |
| 2483 |
* @param string $action 'add' | 'remove'. |
| 2484 |
* @param string[] $positional [id-or-login, capability]. |
| 2485 |
* @return array<string,mixed> |
| 2486 |
*/ |
| 2487 |
private function handle_user_cap_change( string $action, array $positional ): array { |
| 2488 |
$ident = $positional[0] ?? ''; |
| 2489 |
$cap = strtolower( $positional[1] ?? '' ); |
| 2490 |
if ( '' === $ident || '' === $cap ) { |
| 2491 |
return Response::error( sprintf( 'Usage: user %s-cap <id-or-login> <capability>', $action ) ); |
| 2492 |
} |
| 2493 |
$user = is_numeric( $ident ) ? get_user_by( 'id', (int) $ident ) : get_user_by( 'login', $ident ); |
| 2494 |
if ( ! $user ) { |
| 2495 |
return Response::error( sprintf( 'User "%s" not found.', $ident ) ); |
| 2496 |
} |
| 2497 |
|
| 2498 |
if ( 'add' === $action ) { |
| 2499 |
$user->add_cap( $cap ); |
| 2500 |
} elseif ( 'remove' === $action ) { |
| 2501 |
$user->remove_cap( $cap ); |
| 2502 |
} else { |
| 2503 |
return Response::error( sprintf( 'Unsupported cap action: "%s".', $action ) ); |
| 2504 |
} |
| 2505 |
|
| 2506 |
return Response::success( |
| 2507 |
array( |
| 2508 |
'ID' => (int) $user->ID, |
| 2509 |
'login' => $user->user_login, |
| 2510 |
'capability' => $cap, |
| 2511 |
'action' => $action, |
| 2512 |
'has_now' => user_can( $user->ID, $cap ), |
| 2513 |
) |
| 2514 |
); |
| 2515 |
} |
| 2516 |
|
| 2517 |
// ─── Menu handlers ────────────────────────────────────────────────────────── |
| 2518 |
|
| 2519 |
/** |
| 2520 |
* Handle "menu list". |
| 2521 |
* |
| 2522 |
* @param array<string,mixed> $flags Parsed flags. |
| 2523 |
* @return array<string,mixed> |
| 2524 |
*/ |
| 2525 |
private function handle_menu_list( array $flags ): array { |
| 2526 |
unset( $flags ); |
| 2527 |
$menus = wp_get_nav_menus(); |
| 2528 |
$out = array(); |
| 2529 |
foreach ( $menus as $m ) { |
| 2530 |
$out[] = array( |
| 2531 |
'term_id' => (int) $m->term_id, |
| 2532 |
'name' => $m->name, |
| 2533 |
'slug' => $m->slug, |
| 2534 |
'count' => (int) $m->count, |
| 2535 |
); |
| 2536 |
} |
| 2537 |
return Response::success( $out ); |
| 2538 |
} |
| 2539 |
|
| 2540 |
/** |
| 2541 |
* Handle "menu create". |
| 2542 |
* |
| 2543 |
* @param string[] $positional Remaining positional args. |
| 2544 |
* @return array<string,mixed> |
| 2545 |
*/ |
| 2546 |
private function handle_menu_create( array $positional ): array { |
| 2547 |
$name = $positional[0] ?? ''; |
| 2548 |
if ( '' === $name ) { |
| 2549 |
return Response::error( 'Usage: menu create <name>' ); |
| 2550 |
} |
| 2551 |
$id = wp_create_nav_menu( $name ); |
| 2552 |
if ( is_wp_error( $id ) ) { |
| 2553 |
return Response::error( $id->get_error_message() ); |
| 2554 |
} |
| 2555 |
return Response::success( array( 'term_id' => (int) $id ) ); |
| 2556 |
} |
| 2557 |
|
| 2558 |
/** |
| 2559 |
* Route "menu item ..." subcommands. |
| 2560 |
* |
| 2561 |
* @param string[] $positional Remaining positional args after "menu item". |
| 2562 |
* @param array<string,mixed> $flags Parsed flags. |
| 2563 |
* @return array<string,mixed> |
| 2564 |
*/ |
| 2565 |
private function route_menu_item( array $positional, array $flags ): array { |
| 2566 |
$verb = strtolower( $positional[0] ?? '' ); |
| 2567 |
|
| 2568 |
switch ( $verb ) { |
| 2569 |
case 'list': |
| 2570 |
$menu = $positional[1] ?? ''; |
| 2571 |
if ( '' === $menu ) { |
| 2572 |
return Response::error( 'Usage: menu item list <menu>' ); |
| 2573 |
} |
| 2574 |
$items = wp_get_nav_menu_items( $menu ); |
| 2575 |
if ( false === $items ) { |
| 2576 |
return Response::error( sprintf( 'Menu "%s" not found.', $menu ) ); |
| 2577 |
} |
| 2578 |
$out = array(); |
| 2579 |
foreach ( $items as $item ) { |
| 2580 |
if ( ! is_object( $item ) ) { |
| 2581 |
continue; |
| 2582 |
} |
| 2583 |
/** |
| 2584 |
* Narrowed type for `$item`. |
| 2585 |
* |
| 2586 |
* @var object{db_id:int,type:string,object:string,object_id:int,title:string,url:string,menu_item_parent:int} $item |
| 2587 |
*/ |
| 2588 |
$out[] = array( |
| 2589 |
'db_id' => Utils::to_int( $item->db_id ), |
| 2590 |
'type' => $item->type, |
| 2591 |
'object' => $item->object, |
| 2592 |
'object_id' => Utils::to_int( $item->object_id ), |
| 2593 |
'title' => $item->title, |
| 2594 |
'url' => $item->url, |
| 2595 |
'parent' => Utils::to_int( $item->menu_item_parent ), |
| 2596 |
); |
| 2597 |
} |
| 2598 |
return Response::success( $out ); |
| 2599 |
|
| 2600 |
case 'add-post': |
| 2601 |
case 'add_post': |
| 2602 |
$menu_id = isset( $positional[1] ) ? (int) $positional[1] : 0; |
| 2603 |
$post_id = isset( $positional[2] ) ? (int) $positional[2] : 0; |
| 2604 |
if ( $menu_id <= 0 || $post_id <= 0 ) { |
| 2605 |
return Response::error( 'Usage: menu item add-post <menu-id> <post-id>' ); |
| 2606 |
} |
| 2607 |
$post = get_post( $post_id ); |
| 2608 |
if ( ! $post ) { |
| 2609 |
return Response::error( sprintf( 'Post %d not found.', $post_id ) ); |
| 2610 |
} |
| 2611 |
$item_id = wp_update_nav_menu_item( |
| 2612 |
$menu_id, |
| 2613 |
0, |
| 2614 |
array( |
| 2615 |
'menu-item-title' => $post->post_title, |
| 2616 |
'menu-item-object' => $post->post_type, |
| 2617 |
'menu-item-object-id' => $post_id, |
| 2618 |
'menu-item-type' => 'post_type', |
| 2619 |
'menu-item-status' => 'publish', |
| 2620 |
'menu-item-parent-id' => isset( $flags['parent-id'] ) ? Utils::to_int( $flags['parent-id'] ) : 0, |
| 2621 |
) |
| 2622 |
); |
| 2623 |
if ( is_wp_error( $item_id ) ) { |
| 2624 |
return Response::error( $item_id->get_error_message() ); |
| 2625 |
} |
| 2626 |
return Response::success( array( 'menu_item_id' => (int) $item_id ) ); |
| 2627 |
} |
| 2628 |
|
| 2629 |
return Response::error( sprintf( 'Unknown "menu item" subcommand: "%s"', $verb ) ); |
| 2630 |
} |
| 2631 |
|
| 2632 |
// ─── Sidebar / widget / cron handlers ─────────────────────────────────────── |
| 2633 |
|
| 2634 |
/** |
| 2635 |
* Handle "sidebar list". |
| 2636 |
* |
| 2637 |
* @return array<string,mixed> |
| 2638 |
*/ |
| 2639 |
private function handle_sidebar_list(): array { |
| 2640 |
global $wp_registered_sidebars; |
| 2641 |
$out = array(); |
| 2642 |
foreach ( (array) $wp_registered_sidebars as $id => $sidebar ) { |
| 2643 |
if ( ! is_array( $sidebar ) ) { |
| 2644 |
continue; |
| 2645 |
} |
| 2646 |
$out[] = array( |
| 2647 |
'id' => $id, |
| 2648 |
'name' => $sidebar['name'] ?? '', |
| 2649 |
'description' => $sidebar['description'] ?? '', |
| 2650 |
); |
| 2651 |
} |
| 2652 |
return Response::success( $out ); |
| 2653 |
} |
| 2654 |
|
| 2655 |
/** |
| 2656 |
* Handle "widget list <sidebar-id>". |
| 2657 |
* |
| 2658 |
* @param string[] $positional Remaining positional args. |
| 2659 |
* @return array<string,mixed> |
| 2660 |
*/ |
| 2661 |
private function handle_widget_list( array $positional ): array { |
| 2662 |
$sidebar_id = $positional[0] ?? ''; |
| 2663 |
if ( '' === $sidebar_id ) { |
| 2664 |
return Response::error( 'Usage: widget list <sidebar-id>' ); |
| 2665 |
} |
| 2666 |
// `wp_get_sidebars_widgets()` is internal/private in WP core and is |
| 2667 |
// blocked by WPCS. Read the `sidebars_widgets` option directly — it |
| 2668 |
// carries the same sidebar→widgets map. |
| 2669 |
$map = get_option( 'sidebars_widgets', array() ); |
| 2670 |
if ( ! is_array( $map ) ) { |
| 2671 |
$map = array(); |
| 2672 |
} |
| 2673 |
$widgets = isset( $map[ $sidebar_id ] ) && is_array( $map[ $sidebar_id ] ) ? $map[ $sidebar_id ] : array(); |
| 2674 |
return Response::success( array_values( $widgets ) ); |
| 2675 |
} |
| 2676 |
|
| 2677 |
/** |
| 2678 |
* Handle "cron event list". |
| 2679 |
* |
| 2680 |
* @return array<string,mixed> |
| 2681 |
*/ |
| 2682 |
private function handle_cron_event_list(): array { |
| 2683 |
$crons = _get_cron_array(); |
| 2684 |
$out = array(); |
| 2685 |
foreach ( $crons as $timestamp => $hooks ) { |
| 2686 |
foreach ( (array) $hooks as $hook => $dings ) { |
| 2687 |
foreach ( (array) $dings as $sig => $data ) { |
| 2688 |
if ( ! is_array( $data ) ) { |
| 2689 |
continue; |
| 2690 |
} |
| 2691 |
$out[] = array( |
| 2692 |
'hook' => $hook, |
| 2693 |
'next_run' => (int) $timestamp, |
| 2694 |
'schedule' => $data['schedule'] ?? false, |
| 2695 |
'interval' => $data['interval'] ?? null, |
| 2696 |
'sig' => $sig, |
| 2697 |
'args_count' => is_array( $data['args'] ?? null ) ? count( $data['args'] ) : 0, |
| 2698 |
); |
| 2699 |
} |
| 2700 |
} |
| 2701 |
} |
| 2702 |
return Response::success( $out ); |
| 2703 |
} |
| 2704 |
|
| 2705 |
// ─── Term handlers ────────────────────────────────────────────────────────── |
| 2706 |
|
| 2707 |
/** |
| 2708 |
* Handle "term list <taxonomy>". |
| 2709 |
* |
| 2710 |
* @param string[] $positional Remaining positional args (taxonomy at [0]). |
| 2711 |
* @param array<string,mixed> $flags Parsed flags. |
| 2712 |
* @return array<string,mixed> |
| 2713 |
*/ |
| 2714 |
private function handle_term_list( array $positional, array $flags ): array { |
| 2715 |
$taxonomy = Utils::to_str( $positional[0] ?? $flags['taxonomy'] ?? '' ); |
| 2716 |
if ( '' === $taxonomy ) { |
| 2717 |
return Response::error( 'Usage: term list <taxonomy> [--number=…] [--hide_empty=true|false]' ); |
| 2718 |
} |
| 2719 |
if ( ! taxonomy_exists( $taxonomy ) ) { |
| 2720 |
return Response::error( sprintf( 'Taxonomy "%s" is not registered.', $taxonomy ) ); |
| 2721 |
} |
| 2722 |
|
| 2723 |
$hide_empty = isset( $flags['hide_empty'] ) |
| 2724 |
? filter_var( $flags['hide_empty'], FILTER_VALIDATE_BOOLEAN ) |
| 2725 |
: false; |
| 2726 |
|
| 2727 |
$filter_args = array( |
| 2728 |
'taxonomy' => $taxonomy, |
| 2729 |
'hide_empty' => $hide_empty, |
| 2730 |
); |
| 2731 |
if ( isset( $flags['parent'] ) ) { |
| 2732 |
$filter_args['parent'] = Utils::to_int( $flags['parent'] ); |
| 2733 |
} |
| 2734 |
|
| 2735 |
// Total matching terms across every page — used for `--format=count` |
| 2736 |
// and the pagination envelope. |
| 2737 |
$count = wp_count_terms( $filter_args ); |
| 2738 |
$total = is_wp_error( $count ) ? 0 : (int) $count; |
| 2739 |
if ( $this->wants_count( $flags ) ) { |
| 2740 |
return Response::success( $total ); |
| 2741 |
} |
| 2742 |
|
| 2743 |
// Terms return ALL matches unless a page size is given (mirrors |
| 2744 |
// `wp term list`); `--per_page=-1` is the explicit "all" idiom. When a |
| 2745 |
// positive size is given, page deterministically. per_page === 0 means |
| 2746 |
// "no limit" for get_terms. |
| 2747 |
$size_raw = $flags['number'] ?? $flags['per_page'] ?? null; |
| 2748 |
if ( null === $size_raw || Utils::to_int( $size_raw ) < 0 ) { |
| 2749 |
$per_page = 0; |
| 2750 |
} else { |
| 2751 |
$per_page = max( 1, min( Utils::to_int( $size_raw ), 1000 ) ); |
| 2752 |
} |
| 2753 |
if ( isset( $flags['offset'] ) ) { |
| 2754 |
$offset = max( 0, Utils::to_int( $flags['offset'] ) ); |
| 2755 |
} elseif ( $per_page && isset( $flags['page'] ) ) { |
| 2756 |
$offset = ( max( 1, Utils::to_int( $flags['page'] ) ) - 1 ) * $per_page; |
| 2757 |
} else { |
| 2758 |
$offset = 0; |
| 2759 |
} |
| 2760 |
|
| 2761 |
$query_args = $filter_args; |
| 2762 |
if ( $per_page ) { |
| 2763 |
$query_args['number'] = $per_page; |
| 2764 |
$query_args['offset'] = $offset; |
| 2765 |
} |
| 2766 |
|
| 2767 |
// `--format=ids`: flat list of term IDs. |
| 2768 |
if ( $this->wants_ids( $flags ) ) { |
| 2769 |
$ids = get_terms( array_merge( $query_args, array( 'fields' => 'ids' ) ) ); |
| 2770 |
if ( is_wp_error( $ids ) ) { |
| 2771 |
return Response::error( $ids->get_error_message() ); |
| 2772 |
} |
| 2773 |
$ids = array_map( 'intval', $ids ); |
| 2774 |
return $this->paginated_response( $ids, $total, $per_page ? $per_page : max( 1, count( $ids ) ), $offset ); |
| 2775 |
} |
| 2776 |
|
| 2777 |
$terms = get_terms( $query_args ); |
| 2778 |
if ( is_wp_error( $terms ) ) { |
| 2779 |
return Response::error( $terms->get_error_message() ); |
| 2780 |
} |
| 2781 |
|
| 2782 |
$out = array(); |
| 2783 |
foreach ( $terms as $term ) { |
| 2784 |
$out[] = array( |
| 2785 |
'term_id' => (int) $term->term_id, |
| 2786 |
'name' => $term->name, |
| 2787 |
'slug' => $term->slug, |
| 2788 |
'count' => (int) $term->count, |
| 2789 |
'taxonomy' => $term->taxonomy, |
| 2790 |
'description' => $term->description, |
| 2791 |
'parent' => (int) $term->parent, |
| 2792 |
); |
| 2793 |
} |
| 2794 |
return $this->paginated_response( $out, $total, $per_page ? $per_page : max( 1, count( $out ) ), $offset ); |
| 2795 |
} |
| 2796 |
|
| 2797 |
// ─── Schema discovery handlers (post-type / taxonomy list) ────────────────── |
| 2798 |
|
| 2799 |
/** |
| 2800 |
* Handle `post-type list [--public=true|false] [--hierarchical=true|false] |
| 2801 |
* [--show_in_menu=…] [--show_in_rest=…] [--format=…]`. |
| 2802 |
* |
| 2803 |
* Mirrors WP-CLI's `wp post-type list` — returns one row per registered |
| 2804 |
* post type with the fields the LLM most commonly needs to decide |
| 2805 |
* downstream operations (which post type to query, whether it's REST- |
| 2806 |
* exposed, what its rest_base is for /wp/v2/<base>). |
| 2807 |
* |
| 2808 |
* @param array<string,mixed> $flags Parsed flags. |
| 2809 |
* @return array<string,mixed> |
| 2810 |
*/ |
| 2811 |
private function handle_post_type_list( array $flags ): array { |
| 2812 |
$query = array(); |
| 2813 |
$bool_filters = array( 'public', 'hierarchical', 'show_in_menu', 'show_in_rest', 'show_ui', 'has_archive' ); |
| 2814 |
foreach ( $bool_filters as $key ) { |
| 2815 |
if ( isset( $flags[ $key ] ) ) { |
| 2816 |
$query[ $key ] = filter_var( $flags[ $key ], FILTER_VALIDATE_BOOLEAN ); |
| 2817 |
} |
| 2818 |
} |
| 2819 |
|
| 2820 |
$types = get_post_types( $query, 'objects' ); |
| 2821 |
|
| 2822 |
$out = array(); |
| 2823 |
foreach ( $types as $type ) { |
| 2824 |
/** |
| 2825 |
* Narrowed type for `$cap_type`. |
| 2826 |
* |
| 2827 |
* @var string|array<int,string> $cap_type |
| 2828 |
*/ |
| 2829 |
$cap_type = $type->capability_type; |
| 2830 |
$out[] = array( |
| 2831 |
'name' => $type->name, |
| 2832 |
'label' => $type->label, |
| 2833 |
'description' => $type->description, |
| 2834 |
'public' => (bool) $type->public, |
| 2835 |
'hierarchical' => (bool) $type->hierarchical, |
| 2836 |
'show_in_menu' => is_bool( $type->show_in_menu ) ? $type->show_in_menu : (bool) $type->show_in_menu, |
| 2837 |
'show_in_rest' => (bool) $type->show_in_rest, |
| 2838 |
'rest_base' => $type->rest_base ? $type->rest_base : $type->name, |
| 2839 |
'has_archive' => is_bool( $type->has_archive ) ? $type->has_archive : (bool) $type->has_archive, |
| 2840 |
'capability_type' => is_array( $cap_type ) ? implode( ',', $cap_type ) : (string) $cap_type, |
| 2841 |
'_builtin' => (bool) $type->_builtin, |
| 2842 |
); |
| 2843 |
} |
| 2844 |
return Response::success( $out ); |
| 2845 |
} |
| 2846 |
|
| 2847 |
/** |
| 2848 |
* Handle `taxonomy list [--public=true|false] [--hierarchical=…] |
| 2849 |
* [--object_type=<post_type>] [--format=…]`. |
| 2850 |
* |
| 2851 |
* Mirrors WP-CLI's `wp taxonomy list`. |
| 2852 |
* |
| 2853 |
* @param array<string,mixed> $flags Parsed flags. |
| 2854 |
* @return array<string,mixed> |
| 2855 |
*/ |
| 2856 |
private function handle_taxonomy_list( array $flags ): array { |
| 2857 |
$query = array(); |
| 2858 |
$bool_filters = array( 'public', 'hierarchical', 'show_in_menu', 'show_in_rest', 'show_ui' ); |
| 2859 |
foreach ( $bool_filters as $key ) { |
| 2860 |
if ( isset( $flags[ $key ] ) ) { |
| 2861 |
$query[ $key ] = filter_var( $flags[ $key ], FILTER_VALIDATE_BOOLEAN ); |
| 2862 |
} |
| 2863 |
} |
| 2864 |
|
| 2865 |
$object_type_filter = isset( $flags['object_type'] ) ? Utils::to_str( $flags['object_type'] ) : ''; |
| 2866 |
|
| 2867 |
$taxonomies = get_taxonomies( $query, 'objects' ); |
| 2868 |
|
| 2869 |
$out = array(); |
| 2870 |
foreach ( $taxonomies as $tax ) { |
| 2871 |
// --object_type filters down to taxonomies attached to that post type. |
| 2872 |
if ( '' !== $object_type_filter && ! in_array( $object_type_filter, $tax->object_type, true ) ) { |
| 2873 |
continue; |
| 2874 |
} |
| 2875 |
$out[] = array( |
| 2876 |
'name' => $tax->name, |
| 2877 |
'label' => $tax->label, |
| 2878 |
'description' => $tax->description, |
| 2879 |
'public' => (bool) $tax->public, |
| 2880 |
'hierarchical' => (bool) $tax->hierarchical, |
| 2881 |
'object_type' => array_values( $tax->object_type ), |
| 2882 |
'show_in_rest' => (bool) $tax->show_in_rest, |
| 2883 |
'rest_base' => $tax->rest_base ? $tax->rest_base : $tax->name, |
| 2884 |
'_builtin' => (bool) $tax->_builtin, |
| 2885 |
); |
| 2886 |
} |
| 2887 |
return Response::success( $out ); |
| 2888 |
} |
| 2889 |
|
| 2890 |
// ─── Diagnostics: role / db / rewrite / cron / env ────────────────────────── |
| 2891 |
|
| 2892 |
/** |
| 2893 |
* Handle `role list [--fields=role,name,capabilities] [--format=json]`. |
| 2894 |
* |
| 2895 |
* Mirrors `wp role list`. Returns one row per registered role with a |
| 2896 |
* count of granted capabilities and a `builtin` flag for the WP core |
| 2897 |
* roles. Use `role list-caps <role>` for the full cap set. |
| 2898 |
* |
| 2899 |
* @param array<string,mixed> $flags Parsed flags (unused except format passthrough). |
| 2900 |
* @return array<string,mixed> |
| 2901 |
*/ |
| 2902 |
private function handle_role_list( array $flags ): array { |
| 2903 |
unset( $flags ); |
| 2904 |
$roles_obj = wp_roles(); |
| 2905 |
$builtin = array( 'administrator', 'editor', 'author', 'contributor', 'subscriber' ); |
| 2906 |
$out = array(); |
| 2907 |
foreach ( $roles_obj->roles as $slug => $info ) { |
| 2908 |
$caps_array = isset( $info['capabilities'] ) && is_array( $info['capabilities'] ) ? $info['capabilities'] : array(); |
| 2909 |
// Only count caps explicitly granted (true). WordPress stores |
| 2910 |
// removed caps as `false`, which `array_filter` strips out. |
| 2911 |
$granted = array_filter( $caps_array ); |
| 2912 |
$out[] = array( |
| 2913 |
'role' => $slug, |
| 2914 |
'name' => isset( $info['name'] ) ? translate_user_role( Utils::to_str( $info['name'] ) ) : $slug, |
| 2915 |
'capabilities' => count( $granted ), |
| 2916 |
'builtin' => in_array( $slug, $builtin, true ), |
| 2917 |
); |
| 2918 |
} |
| 2919 |
return Response::success( $out ); |
| 2920 |
} |
| 2921 |
|
| 2922 |
/** |
| 2923 |
* Handle `role list-caps <role>`. |
| 2924 |
* |
| 2925 |
* Returns the capability set explicitly granted to a role. Capabilities |
| 2926 |
* stored as `false` (explicit removals) are omitted from the list to |
| 2927 |
* match WP-CLI's `wp role list-caps` behaviour. |
| 2928 |
* |
| 2929 |
* @param string[] $positional Remaining tokens after `role list-caps`. |
| 2930 |
* @return array<string,mixed> |
| 2931 |
*/ |
| 2932 |
private function handle_role_list_caps( array $positional ): array { |
| 2933 |
$slug = strtolower( $positional[0] ?? '' ); |
| 2934 |
if ( '' === $slug ) { |
| 2935 |
return Response::error( 'Usage: role list-caps <role>' ); |
| 2936 |
} |
| 2937 |
$role = get_role( $slug ); |
| 2938 |
if ( ! $role instanceof \WP_Role ) { |
| 2939 |
return Response::error( sprintf( 'Role "%s" is not registered. Use "role list" to see available roles.', $slug ) ); |
| 2940 |
} |
| 2941 |
$granted = array_keys( array_filter( $role->capabilities ) ); |
| 2942 |
sort( $granted ); |
| 2943 |
return Response::success( |
| 2944 |
array( |
| 2945 |
'role' => $slug, |
| 2946 |
'capabilities' => $granted, |
| 2947 |
'count' => count( $granted ), |
| 2948 |
) |
| 2949 |
); |
| 2950 |
} |
| 2951 |
|
| 2952 |
/** |
| 2953 |
* Handle `db size [--tables] [--human-readable]`. |
| 2954 |
* |
| 2955 |
* Read-only summary built from SHOW TABLE STATUS. Honors the wpdb |
| 2956 |
* prefix so multisite blogs only count their own tables when invoked |
| 2957 |
* inside a subsite context. Returns total size + optional per-table |
| 2958 |
* breakdown when --tables is set. |
| 2959 |
* |
| 2960 |
* No arbitrary SQL is exposed; the verifier's `db query/import/...` |
| 2961 |
* block remains in effect. This is a bounded read. |
| 2962 |
* |
| 2963 |
* @param array<string,mixed> $flags Parsed flags. |
| 2964 |
* @return array<string,mixed> |
| 2965 |
*/ |
| 2966 |
private function handle_db_size( array $flags ): array { |
| 2967 |
global $wpdb; |
| 2968 |
/** |
| 2969 |
* Narrowed type for `$wpdb`. |
| 2970 |
* |
| 2971 |
* @var \wpdb $wpdb |
| 2972 |
*/ |
| 2973 |
$show_tables = ! empty( $flags['tables'] ); |
| 2974 |
$human = ! empty( $flags['human-readable'] ) || ! empty( $flags['human_readable'] ); |
| 2975 |
$prefix_like = $wpdb->esc_like( $wpdb->prefix ) . '%'; |
| 2976 |
|
| 2977 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 2978 |
$rows = $wpdb->get_results( $wpdb->prepare( 'SHOW TABLE STATUS LIKE %s', $prefix_like ), ARRAY_A ); |
| 2979 |
if ( ! is_array( $rows ) ) { |
| 2980 |
return Response::error( 'Failed to read table status from MySQL.' ); |
| 2981 |
} |
| 2982 |
|
| 2983 |
$total = 0; |
| 2984 |
$tables_out = array(); |
| 2985 |
foreach ( $rows as $row ) { |
| 2986 |
$data_len = isset( $row['Data_length'] ) ? Utils::to_int( $row['Data_length'] ) : 0; |
| 2987 |
$index_len = isset( $row['Index_length'] ) ? Utils::to_int( $row['Index_length'] ) : 0; |
| 2988 |
$size = $data_len + $index_len; |
| 2989 |
$total += $size; |
| 2990 |
if ( $show_tables ) { |
| 2991 |
$entry = array( |
| 2992 |
'name' => $row['Name'] ?? '', |
| 2993 |
'rows' => isset( $row['Rows'] ) ? Utils::to_int( $row['Rows'] ) : null, |
| 2994 |
'bytes' => $size, |
| 2995 |
); |
| 2996 |
if ( $human ) { |
| 2997 |
$entry['size'] = size_format( $size, 2 ); |
| 2998 |
} |
| 2999 |
$tables_out[] = $entry; |
| 3000 |
} |
| 3001 |
} |
| 3002 |
|
| 3003 |
if ( $show_tables ) { |
| 3004 |
// Sort descending by bytes — largest tables first, which is |
| 3005 |
// what someone debugging size issues actually wants to see. |
| 3006 |
usort( |
| 3007 |
$tables_out, |
| 3008 |
static function ( $a, $b ) { |
| 3009 |
return $b['bytes'] - $a['bytes']; |
| 3010 |
} |
| 3011 |
); |
| 3012 |
} |
| 3013 |
|
| 3014 |
$payload = array( |
| 3015 |
'total_bytes' => $total, |
| 3016 |
); |
| 3017 |
if ( $human ) { |
| 3018 |
$payload['total_size'] = size_format( $total, 2 ); |
| 3019 |
} |
| 3020 |
if ( $show_tables ) { |
| 3021 |
$payload['tables'] = $tables_out; |
| 3022 |
} |
| 3023 |
return Response::success( $payload ); |
| 3024 |
} |
| 3025 |
|
| 3026 |
/** |
| 3027 |
* Handle `rewrite list`. |
| 3028 |
* |
| 3029 |
* Returns the cached rewrite_rules option as an array of |
| 3030 |
* { match, query } entries. Useful for debugging "why is /foo/ returning |
| 3031 |
* 404?" or "what URL pattern resolves to which post/term?". |
| 3032 |
* |
| 3033 |
* @return array<string,mixed> |
| 3034 |
*/ |
| 3035 |
private function handle_rewrite_list(): array { |
| 3036 |
$rules = get_option( 'rewrite_rules' ); |
| 3037 |
if ( ! is_array( $rules ) || empty( $rules ) ) { |
| 3038 |
return Response::success( |
| 3039 |
array( |
| 3040 |
'count' => 0, |
| 3041 |
'rules' => array(), |
| 3042 |
'note' => 'No rewrite rules cached. Try `rewrite flush` to regenerate them.', |
| 3043 |
) |
| 3044 |
); |
| 3045 |
} |
| 3046 |
$out = array(); |
| 3047 |
foreach ( $rules as $match => $query ) { |
| 3048 |
$out[] = array( |
| 3049 |
'match' => (string) $match, |
| 3050 |
'query' => Utils::to_str( $query ), |
| 3051 |
); |
| 3052 |
} |
| 3053 |
return Response::success( |
| 3054 |
array( |
| 3055 |
'count' => count( $out ), |
| 3056 |
'rules' => $out, |
| 3057 |
) |
| 3058 |
); |
| 3059 |
} |
| 3060 |
|
| 3061 |
/** |
| 3062 |
* Handle `cron schedule list`. |
| 3063 |
* |
| 3064 |
* Returns every registered cron interval (`hourly`, `daily`, `twicedaily`, |
| 3065 |
* `weekly`, plus any custom schedules registered via the |
| 3066 |
* `cron_schedules` filter). Used when scheduling a new cron event needs |
| 3067 |
* to pick a valid recurrence. |
| 3068 |
* |
| 3069 |
* @return array<string,mixed> |
| 3070 |
*/ |
| 3071 |
private function handle_cron_schedule_list(): array { |
| 3072 |
$schedules = wp_get_schedules(); |
| 3073 |
$out = array(); |
| 3074 |
foreach ( $schedules as $slug => $info ) { |
| 3075 |
$out[] = array( |
| 3076 |
'name' => (string) $slug, |
| 3077 |
'display' => (string) $info['display'], |
| 3078 |
'interval' => (int) $info['interval'], |
| 3079 |
); |
| 3080 |
} |
| 3081 |
usort( |
| 3082 |
$out, |
| 3083 |
static function ( $a, $b ) { |
| 3084 |
return $a['interval'] - $b['interval']; |
| 3085 |
} |
| 3086 |
); |
| 3087 |
return Response::success( $out ); |
| 3088 |
} |
| 3089 |
|
| 3090 |
/** |
| 3091 |
* Handle `env`. |
| 3092 |
* |
| 3093 |
* Synthesised environment / diagnostic report covering the questions |
| 3094 |
* users ask most often during troubleshooting: which PHP / MySQL / |
| 3095 |
* WordPress are running, where is the install, multisite status, |
| 3096 |
* debug flags, file-mod gates, memory limits, active locale. |
| 3097 |
* |
| 3098 |
* Read-only. Does NOT expose secrets (DB password, auth keys, etc.). |
| 3099 |
* |
| 3100 |
* @return array<string,mixed> |
| 3101 |
*/ |
| 3102 |
private function handle_env(): array { |
| 3103 |
global $wpdb; |
| 3104 |
/** |
| 3105 |
* Narrowed type for `$wpdb`. |
| 3106 |
* |
| 3107 |
* @var \wpdb $wpdb |
| 3108 |
*/ |
| 3109 |
$mysql_version = ''; |
| 3110 |
if ( isset( $wpdb->dbh ) ) { |
| 3111 |
// $wpdb exposes a public helper since WP 5.1. |
| 3112 |
$mysql_version = (string) $wpdb->db_version(); |
| 3113 |
} |
| 3114 |
|
| 3115 |
return Response::success( |
| 3116 |
array( |
| 3117 |
'wp' => array( |
| 3118 |
'version' => get_bloginfo( 'version' ), |
| 3119 |
'site_url' => get_option( 'siteurl' ), |
| 3120 |
'home_url' => get_option( 'home' ), |
| 3121 |
'abspath' => defined( 'ABSPATH' ) ? ABSPATH : '', |
| 3122 |
'language' => get_locale(), |
| 3123 |
'multisite' => is_multisite(), |
| 3124 |
'debug' => defined( 'WP_DEBUG' ) && WP_DEBUG, |
| 3125 |
'debug_log' => defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG, |
| 3126 |
'script_debug' => defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG, |
| 3127 |
'disallow_file_mods' => ! wp_is_file_mod_allowed( 'zipai_env_report' ), |
| 3128 |
'disallow_file_edit' => defined( 'DISALLOW_FILE_EDIT' ) && DISALLOW_FILE_EDIT, |
| 3129 |
'fs_method' => defined( 'FS_METHOD' ) ? Utils::to_str( constant( 'FS_METHOD' ) ) : '', |
| 3130 |
'memory_limit' => defined( 'WP_MEMORY_LIMIT' ) ? WP_MEMORY_LIMIT : '', |
| 3131 |
'max_memory' => defined( 'WP_MAX_MEMORY_LIMIT' ) ? WP_MAX_MEMORY_LIMIT : '', |
| 3132 |
'table_prefix' => $wpdb->prefix, |
| 3133 |
), |
| 3134 |
'php' => array( |
| 3135 |
'version' => PHP_VERSION, |
| 3136 |
'sapi' => PHP_SAPI, |
| 3137 |
'memory_limit' => (string) ini_get( 'memory_limit' ), |
| 3138 |
'max_execution_time' => (int) ini_get( 'max_execution_time' ), |
| 3139 |
'upload_max_filesize' => (string) ini_get( 'upload_max_filesize' ), |
| 3140 |
'post_max_size' => (string) ini_get( 'post_max_size' ), |
| 3141 |
'extensions' => array( |
| 3142 |
'curl' => extension_loaded( 'curl' ), |
| 3143 |
'gd' => extension_loaded( 'gd' ), |
| 3144 |
'imagick' => extension_loaded( 'imagick' ), |
| 3145 |
'mbstring' => extension_loaded( 'mbstring' ), |
| 3146 |
'mysqli' => extension_loaded( 'mysqli' ), |
| 3147 |
'openssl' => extension_loaded( 'openssl' ), |
| 3148 |
'sodium' => extension_loaded( 'sodium' ), |
| 3149 |
'zip' => extension_loaded( 'zip' ), |
| 3150 |
), |
| 3151 |
), |
| 3152 |
'mysql' => array( |
| 3153 |
'version' => $mysql_version, |
| 3154 |
), |
| 3155 |
'theme' => array( |
| 3156 |
'stylesheet' => Utils::to_str( get_option( 'stylesheet' ) ), |
| 3157 |
'template' => Utils::to_str( get_option( 'template' ) ), |
| 3158 |
), |
| 3159 |
) |
| 3160 |
); |
| 3161 |
} |
| 3162 |
|
| 3163 |
/** |
| 3164 |
* Handle `core check-update [--format=json]`. |
| 3165 |
* |
| 3166 |
* Reports core updates WordPress already knows about, read from the |
| 3167 |
* cached `update_core` site transient that core's own scheduled |
| 3168 |
* `wp_version_check()` populates. It does NOT force a live wp.org |
| 3169 |
* request: a web/REST request must stay fast and cannot block on an |
| 3170 |
* outbound call, and WordPress's own Site Health screen reads the |
| 3171 |
* same cached data. Returns an empty `updates` list when the install |
| 3172 |
* is up to date (or when the cache hasn't been primed yet). |
| 3173 |
* |
| 3174 |
* Mirrors the useful half of `wp core check-update`. |
| 3175 |
* |
| 3176 |
* @return array<string,mixed> |
| 3177 |
*/ |
| 3178 |
private function handle_core_check_update(): array { |
| 3179 |
if ( ! function_exists( 'get_core_updates' ) ) { |
| 3180 |
require_once ABSPATH . 'wp-admin/includes/update.php'; |
| 3181 |
} |
| 3182 |
|
| 3183 |
$current = get_bloginfo( 'version' ); |
| 3184 |
$updates = function_exists( 'get_core_updates' ) ? get_core_updates() : array(); |
| 3185 |
if ( ! is_array( $updates ) ) { |
| 3186 |
$updates = array(); |
| 3187 |
} |
| 3188 |
|
| 3189 |
$available = array(); |
| 3190 |
foreach ( $updates as $update ) { |
| 3191 |
if ( ! is_object( $update ) ) { |
| 3192 |
continue; |
| 3193 |
} |
| 3194 |
// `response === 'upgrade'` is the only state that offers a newer |
| 3195 |
// core build; 'latest' / 'development' rows are not actionable. |
| 3196 |
if ( ! isset( $update->response ) || 'upgrade' !== $update->response ) { |
| 3197 |
continue; |
| 3198 |
} |
| 3199 |
$offered = isset( $update->current ) ? Utils::to_str( $update->current ) : ''; |
| 3200 |
if ( '' !== $offered && version_compare( $offered, $current, '<=' ) ) { |
| 3201 |
continue; |
| 3202 |
} |
| 3203 |
$available[] = array( |
| 3204 |
'version' => $offered, |
| 3205 |
'locale' => isset( $update->locale ) ? Utils::to_str( $update->locale, 'en_US' ) : 'en_US', |
| 3206 |
'package' => ( isset( $update->packages ) && is_object( $update->packages ) && isset( $update->packages->full ) ) ? Utils::to_str( $update->packages->full ) : '', |
| 3207 |
'php_version' => isset( $update->php_version ) ? Utils::to_str( $update->php_version ) : '', |
| 3208 |
'mysql_version' => isset( $update->mysql_version ) ? Utils::to_str( $update->mysql_version ) : '', |
| 3209 |
); |
| 3210 |
} |
| 3211 |
|
| 3212 |
return Response::success( |
| 3213 |
array( |
| 3214 |
'current_version' => $current, |
| 3215 |
'update_available' => ! empty( $available ), |
| 3216 |
'updates' => $available, |
| 3217 |
'note' => empty( $updates ) |
| 3218 |
? 'Core update cache is empty — WordPress has not run its scheduled version check yet. "update_available: false" here means "no known update", not a guaranteed up-to-date result.' |
| 3219 |
: '', |
| 3220 |
) |
| 3221 |
); |
| 3222 |
} |
| 3223 |
|
| 3224 |
/** |
| 3225 |
* Handle `cli info`. |
| 3226 |
* |
| 3227 |
* There is no WP-CLI binary in web/REST context, so report the PHP and |
| 3228 |
* WordPress runtime the request is actually executing under — the half |
| 3229 |
* of `wp cli info` that is meaningful here. Keeps callers that probe |
| 3230 |
* `cli info` for runtime facts (e.g. a Site Health snapshot) working |
| 3231 |
* instead of erroring out on the default "not available" branch. |
| 3232 |
* |
| 3233 |
* @return array<string,mixed> |
| 3234 |
*/ |
| 3235 |
private function handle_cli_info(): array { |
| 3236 |
return Response::success( |
| 3237 |
array( |
| 3238 |
'php_binary' => defined( 'PHP_BINARY' ) ? PHP_BINARY : '', |
| 3239 |
'php_version' => PHP_VERSION, |
| 3240 |
'php_sapi' => PHP_SAPI, |
| 3241 |
'wp_version' => get_bloginfo( 'version' ), |
| 3242 |
'wp_cli' => false, |
| 3243 |
'context' => 'web-rest', |
| 3244 |
'note' => 'Running in web/REST context — no WP-CLI runtime. Reported values reflect the PHP/WordPress process serving this request.', |
| 3245 |
) |
| 3246 |
); |
| 3247 |
} |
| 3248 |
|
| 3249 |
// ─── Search-replace handler ───────────────────────────────────────────────── |
| 3250 |
// `handle_search_replace()` and its serialized-PHP walker live in |
| 3251 |
// Search_Replace_Engine_Trait (search-replace-engine-trait.php). |
| 3252 |
|
| 3253 |
// ─── Shared helpers ───────────────────────────────────────────────────────── |
| 3254 |
|
| 3255 |
/** |
| 3256 |
* Split parsed args into positional tokens and --flag map. |
| 3257 |
* |
| 3258 |
* Supports `--key=value`, `--key value`, and boolean `--key`. |
| 3259 |
* |
| 3260 |
* @param string[] $args Parsed command tokens. |
| 3261 |
* @return array{0:string[],1:array<string,bool|string>} |
| 3262 |
*/ |
| 3263 |
private function parse_flags( array $args ): array { |
| 3264 |
$positional = array(); |
| 3265 |
$flags = array(); |
| 3266 |
$pending_key = null; |
| 3267 |
$value_flags = array( |
| 3268 |
'status', |
| 3269 |
'format', |
| 3270 |
'fields', |
| 3271 |
'post_type', |
| 3272 |
'post_status', |
| 3273 |
'posts_per_page', |
| 3274 |
'per_page', |
| 3275 |
'page', |
| 3276 |
'offset', |
| 3277 |
'orderby', |
| 3278 |
'order', |
| 3279 |
's', |
| 3280 |
'search', |
| 3281 |
'number', |
| 3282 |
'role', |
| 3283 |
'hide_empty', |
| 3284 |
'expiration', |
| 3285 |
'autoload', |
| 3286 |
'reassign', |
| 3287 |
'parent-id', |
| 3288 |
'post_title', |
| 3289 |
'post_content', |
| 3290 |
'post_name', |
| 3291 |
'post_excerpt', |
| 3292 |
'post_author', |
| 3293 |
'menu_order', |
| 3294 |
'slug', |
| 3295 |
'parent', |
| 3296 |
'description', |
| 3297 |
'name', |
| 3298 |
'user_login', |
| 3299 |
'user_email', |
| 3300 |
'user_pass', |
| 3301 |
'first_name', |
| 3302 |
'last_name', |
| 3303 |
'display_name', |
| 3304 |
'limit', |
| 3305 |
'skip-columns', |
| 3306 |
'include-columns', |
| 3307 |
'taxonomy', |
| 3308 |
// comment list filters + create/update fields. |
| 3309 |
'post_id', |
| 3310 |
'type', |
| 3311 |
'comment_post_ID', |
| 3312 |
'comment_content', |
| 3313 |
'comment_author', |
| 3314 |
'comment_author_email', |
| 3315 |
'comment_author_url', |
| 3316 |
'comment_approved', |
| 3317 |
'comment_parent', |
| 3318 |
'comment_type', |
| 3319 |
'comment_date', |
| 3320 |
'user_id', |
| 3321 |
// post-type / taxonomy schema-discovery filters. |
| 3322 |
'public', |
| 3323 |
'hierarchical', |
| 3324 |
'show_in_menu', |
| 3325 |
'show_in_rest', |
| 3326 |
'show_ui', |
| 3327 |
'has_archive', |
| 3328 |
'object_type', |
| 3329 |
); |
| 3330 |
|
| 3331 |
foreach ( $args as $arg ) { |
| 3332 |
if ( null !== $pending_key ) { |
| 3333 |
$flags[ $pending_key ] = $arg; |
| 3334 |
$pending_key = null; |
| 3335 |
continue; |
| 3336 |
} |
| 3337 |
if ( str_starts_with( $arg, '--' ) ) { |
| 3338 |
$body = substr( $arg, 2 ); |
| 3339 |
if ( str_contains( $body, '=' ) ) { |
| 3340 |
list( $k, $v ) = explode( '=', $body, 2 ); |
| 3341 |
$flags[ $k ] = $v; |
| 3342 |
} elseif ( in_array( $body, $value_flags, true ) ) { |
| 3343 |
// Value follows in the next token. |
| 3344 |
$pending_key = $body; |
| 3345 |
} else { |
| 3346 |
$flags[ $body ] = true; |
| 3347 |
} |
| 3348 |
continue; |
| 3349 |
} |
| 3350 |
$positional[] = $arg; |
| 3351 |
} |
| 3352 |
|
| 3353 |
if ( null !== $pending_key ) { |
| 3354 |
// Trailing `--key` with no value — treat as boolean true. |
| 3355 |
$flags[ $pending_key ] = true; |
| 3356 |
} |
| 3357 |
|
| 3358 |
return array( $positional, $flags ); |
| 3359 |
} |
| 3360 |
|
| 3361 |
/** |
| 3362 |
* Normalise the --fields flag. |
| 3363 |
* |
| 3364 |
* @param array<string,mixed> $flags Parsed flags. |
| 3365 |
* @param string[]|null $default Default field set, or null for "all". |
| 3366 |
* @return string[]|null |
| 3367 |
*/ |
| 3368 |
private function resolve_fields_flag( array $flags, ?array $default ): ?array { |
| 3369 |
if ( empty( $flags['fields'] ) ) { |
| 3370 |
return $default; |
| 3371 |
} |
| 3372 |
$raw = is_array( $flags['fields'] ) ? $flags['fields'] : explode( ',', Utils::to_str( $flags['fields'] ) ); |
| 3373 |
return array_values( array_filter( array_map( static fn ( $v ) => trim( is_scalar( $v ) ? (string) $v : '' ), $raw ), static fn ( string $s ): bool => '' !== $s ) ); |
| 3374 |
} |
| 3375 |
|
| 3376 |
/** |
| 3377 |
* Serialise a WP_Post to a JSON-friendly array. |
| 3378 |
* |
| 3379 |
* @param \WP_Post $post Post. |
| 3380 |
* @param string[]|null $fields Field subset; null for all. |
| 3381 |
* @return array<string,mixed> |
| 3382 |
*/ |
| 3383 |
private function post_to_array( \WP_Post $post, ?array $fields ): array { |
| 3384 |
$all = array( |
| 3385 |
'ID' => (int) $post->ID, |
| 3386 |
'post_title' => $post->post_title, |
| 3387 |
'post_status' => $post->post_status, |
| 3388 |
'post_type' => $post->post_type, |
| 3389 |
'post_date' => $post->post_date, |
| 3390 |
'post_modified' => $post->post_modified, |
| 3391 |
'post_author' => (int) $post->post_author, |
| 3392 |
'post_parent' => (int) $post->post_parent, |
| 3393 |
'post_name' => $post->post_name, |
| 3394 |
'post_excerpt' => $post->post_excerpt, |
| 3395 |
'menu_order' => (int) $post->menu_order, |
| 3396 |
'comment_status' => $post->comment_status, |
| 3397 |
'ping_status' => $post->ping_status, |
| 3398 |
'guid' => $post->guid, |
| 3399 |
); |
| 3400 |
if ( null === $fields ) { |
| 3401 |
return $all; |
| 3402 |
} |
| 3403 |
$out = array(); |
| 3404 |
foreach ( $fields as $f ) { |
| 3405 |
$out[ $f ] = $all[ $f ] ?? null; |
| 3406 |
} |
| 3407 |
return $out; |
| 3408 |
} |
| 3409 |
|
| 3410 |
/** |
| 3411 |
* Serialise a WP_User to a JSON-friendly array. |
| 3412 |
* |
| 3413 |
* @param \WP_User $user User. |
| 3414 |
* @param string[]|null $fields Field subset; null for all safe fields. |
| 3415 |
* @return array<string,mixed> |
| 3416 |
*/ |
| 3417 |
private function user_to_array( \WP_User $user, ?array $fields ): array { |
| 3418 |
$all = array( |
| 3419 |
'ID' => (int) $user->ID, |
| 3420 |
'user_login' => $user->user_login, |
| 3421 |
'user_email' => $user->user_email, |
| 3422 |
'user_nicename' => $user->user_nicename, |
| 3423 |
'display_name' => $user->display_name, |
| 3424 |
'user_registered' => $user->user_registered, |
| 3425 |
'roles' => $user->roles, |
| 3426 |
); |
| 3427 |
if ( null === $fields ) { |
| 3428 |
return $all; |
| 3429 |
} |
| 3430 |
$out = array(); |
| 3431 |
foreach ( $fields as $f ) { |
| 3432 |
$out[ $f ] = $all[ $f ] ?? null; |
| 3433 |
} |
| 3434 |
return $out; |
| 3435 |
} |
| 3436 |
|
| 3437 |
/** |
| 3438 |
* Translate WP-CLI-style --flags into a wp_insert_post/wp_update_post array. |
| 3439 |
* Skips --post_content (blocked by `verify_command_security`). |
| 3440 |
* |
| 3441 |
* @param array<string,mixed> $flags Parsed flags. |
| 3442 |
* @return array{post_title?:string,post_status?:string,post_type?:string,post_name?:string,post_excerpt?:string,post_author?:int,post_parent?:int,menu_order?:int,comment_status?:string,ping_status?:string,post_date?:string,post_password?:string} |
| 3443 |
*/ |
| 3444 |
private function flags_to_post_data( array $flags ): array { |
| 3445 |
$out = array(); |
| 3446 |
if ( array_key_exists( 'post_title', $flags ) ) { |
| 3447 |
$out['post_title'] = Utils::to_str( $flags['post_title'] ); |
| 3448 |
} |
| 3449 |
if ( array_key_exists( 'post_status', $flags ) ) { |
| 3450 |
$out['post_status'] = Utils::to_str( $flags['post_status'] ); |
| 3451 |
} |
| 3452 |
if ( array_key_exists( 'post_type', $flags ) ) { |
| 3453 |
$out['post_type'] = Utils::to_str( $flags['post_type'] ); |
| 3454 |
} |
| 3455 |
if ( array_key_exists( 'post_name', $flags ) ) { |
| 3456 |
$out['post_name'] = Utils::to_str( $flags['post_name'] ); |
| 3457 |
} |
| 3458 |
if ( array_key_exists( 'post_excerpt', $flags ) ) { |
| 3459 |
$out['post_excerpt'] = Utils::to_str( $flags['post_excerpt'] ); |
| 3460 |
} |
| 3461 |
if ( array_key_exists( 'post_author', $flags ) ) { |
| 3462 |
$out['post_author'] = Utils::to_int( $flags['post_author'] ); |
| 3463 |
} |
| 3464 |
if ( array_key_exists( 'post_parent', $flags ) ) { |
| 3465 |
$out['post_parent'] = Utils::to_int( $flags['post_parent'] ); |
| 3466 |
} |
| 3467 |
if ( array_key_exists( 'menu_order', $flags ) ) { |
| 3468 |
$out['menu_order'] = Utils::to_int( $flags['menu_order'] ); |
| 3469 |
} |
| 3470 |
if ( array_key_exists( 'comment_status', $flags ) ) { |
| 3471 |
$out['comment_status'] = Utils::to_str( $flags['comment_status'] ); |
| 3472 |
} |
| 3473 |
if ( array_key_exists( 'ping_status', $flags ) ) { |
| 3474 |
$out['ping_status'] = Utils::to_str( $flags['ping_status'] ); |
| 3475 |
} |
| 3476 |
if ( array_key_exists( 'post_date', $flags ) ) { |
| 3477 |
$out['post_date'] = Utils::to_str( $flags['post_date'] ); |
| 3478 |
} |
| 3479 |
if ( array_key_exists( 'post_password', $flags ) ) { |
| 3480 |
$out['post_password'] = Utils::to_str( $flags['post_password'] ); |
| 3481 |
} |
| 3482 |
return $out; |
| 3483 |
} |
| 3484 |
|
| 3485 |
// ─── Command parsing ──────────────────────────────────────────────────────── |
| 3486 |
// `parse_command_to_args()` and `parse_output()` live in |
| 3487 |
// Command_Parser_Trait (command-parser-trait.php). |
| 3488 |
} |
| 3489 |
|