feat(v3.13.0): settings live DB reads, remove Directus/Appwrite, admin stats
Settings fixes: - MAX_SITES_PER_USER, USER_RATE_LIMIT_PER_MIN/HR now read from DB settings table (DB > ENV > default), so dashboard/settings changes apply without restart. Sync cache refreshed on every save or delete. - /api/me reports the live DB value for max_sites_per_user. Admin improvements: - Admin users bypass per-user rate limiting entirely (role=admin or ADMIN_EMAILS). - Admin Overview now shows platform stats: registered users, new users (7d), total user sites, available tools. Plugin cleanup: - Appwrite and Directus plugins removed from the active registry (8 plugins now: WordPress, WooCommerce, WordPress Specialist, Gitea, n8n, Supabase, OpenPanel, Coolify). Plugin code is retained for future re-enabling. - Settings page plugin visibility list updated to match. Mobile onboarding: - Stepper steps on narrow viewports stack vertically with correct full border and rounded corners on each step. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
358
plugins/wordpress_specialist/plugin.py
Normal file
358
plugins/wordpress_specialist/plugin.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""WordPress Specialist Plugin (F.19) — companion-backed advanced
|
||||
management for WordPress professionals.
|
||||
|
||||
The companion-backed surface that replaced the deprecated
|
||||
``wordpress_advanced`` plugin (sunset 2026-05-04 in F.19.3.2-.3):
|
||||
|
||||
* ``wordpress_specialist`` — plugins / themes / users / options / cron /
|
||||
maintenance / page editing / site config + layout / db inspection /
|
||||
bulk fan-out via the Airano MCP Bridge companion plugin. Requires
|
||||
only ``url`` + ``username`` + ``app_password`` (the WP user needs
|
||||
``manage_options``). No Docker socket dependency.
|
||||
|
||||
Tool surface (51 tools across read / editor / install / settings /
|
||||
admin tiers — see ``get_tool_specifications`` for the per-section
|
||||
breakdown). Plugin remains admin-only by default — not in
|
||||
``ENABLED_PLUGINS`` until F.19.4 (companion v3.0 wp.org republish)
|
||||
closes the certification loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.base import BasePlugin
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress_specialist import handlers
|
||||
|
||||
|
||||
class WordPressSpecialistPlugin(BasePlugin):
|
||||
"""Advanced WordPress management for specialists, companion-backed."""
|
||||
|
||||
@staticmethod
|
||||
def get_plugin_name() -> str:
|
||||
return "wordpress_specialist"
|
||||
|
||||
@staticmethod
|
||||
def get_required_config_keys() -> list[str]:
|
||||
# No ``container`` — this plugin never shells into WP-CLI.
|
||||
return ["url", "username", "app_password"]
|
||||
|
||||
def __init__(self, config: dict[str, Any], project_id: str | None = None) -> None:
|
||||
super().__init__(config, project_id=project_id)
|
||||
|
||||
self.client = WordPressClient(
|
||||
site_url=config["url"],
|
||||
username=config["username"],
|
||||
app_password=config["app_password"],
|
||||
)
|
||||
|
||||
# F.19.1: read-only management handler. F.19.2 will introduce
|
||||
# additional handlers (e.g. installer.py, users_admin.py) but
|
||||
# all will share self.client and stay companion-backed.
|
||||
self.management = handlers.ManagementHandler(self.client)
|
||||
# F.19.5: page editing handler — Gutenberg blocks + Elementor
|
||||
# + Classic. Companion-backed except for the two read paths
|
||||
# that go through stock REST.
|
||||
self.pages = handlers.PagesHandler(self.client)
|
||||
# F.19.7: theme dev surface — install + activate + delete +
|
||||
# file CRUD. Companion-backed (Airano MCP Bridge v2.14.0+).
|
||||
self.themes = handlers.ThemesHandler(self.client)
|
||||
# F.19.2.1: plugin write management — install / activate /
|
||||
# deactivate / update / delete. Companion-backed (Airano MCP
|
||||
# Bridge v2.15.0+). First handler to use the install + admin
|
||||
# tiers introduced by F.19.2.0.
|
||||
self.plugins = handlers.PluginsHandler(self.client)
|
||||
# F.19.6.A: site config — identity / reading / permalinks.
|
||||
# Companion-backed (Airano MCP Bridge v2.16.0+). First consumer
|
||||
# of the ``settings`` tier introduced by F.19.2.0.
|
||||
self.site_config = handlers.SiteConfigHandler(self.client)
|
||||
# F.19.6.B: site layout — menus / widgets / customizer.
|
||||
# Companion-backed (Airano MCP Bridge v2.17.0+). Same
|
||||
# ``settings`` tier as site config — closes the Settings →
|
||||
# Menus + Appearance → Widgets + Customizer gaps.
|
||||
self.site_layout = handlers.SiteLayoutHandler(self.client)
|
||||
# F.19.3.2-.3: database inspection (companion v2.18.0+) — three
|
||||
# read-only tools (db/size, db/tables, db/search). Bundled with
|
||||
# the wordpress_advanced sunset; no SQL exposure (S-25).
|
||||
self.database = handlers.DatabaseHandler(self.client)
|
||||
# F.19.3.2-.3: bulk fan-out — post + term updates via stock
|
||||
# REST batch. No companion route needed; per-item permission
|
||||
# checks happen at the WP layer (S-26 caps at 50 items/call).
|
||||
self.bulk = handlers.BulkHandler(self.client)
|
||||
|
||||
@staticmethod
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return all tool specs.
|
||||
|
||||
Currently exposes:
|
||||
* F.19.1 read surface — 6 tools (plugins/themes/users/options/
|
||||
cron/maintenance)
|
||||
* F.19.3.1 ports — 3 tools (system_info / phpinfo / disk_usage)
|
||||
* F.19.5 page editing — 11 tools (4 Gutenberg + 6 Elementor + 1
|
||||
Classic)
|
||||
* F.19.7 theme dev surface — 7 tools (3 management + 4 file CRUD)
|
||||
* F.19.2.1 plugin write management — 6 tools (4 install-tier +
|
||||
2 admin-tier)
|
||||
* F.19.6.A site config — 6 tools (identity + reading + permalinks)
|
||||
* F.19.6.B site layout — 7 tools (3 menu + 3 widget + 1 customizer)
|
||||
* F.19.3.2 database inspection — 3 tools (db/size + db/tables + db/search)
|
||||
* F.19.3.3 bulk fan-out — 2 tools (post + term updates)
|
||||
|
||||
Total: 51 tools.
|
||||
"""
|
||||
return [
|
||||
*handlers.get_management_specs(),
|
||||
*handlers.get_pages_specs(),
|
||||
*handlers.get_themes_specs(),
|
||||
*handlers.get_plugins_specs(),
|
||||
*handlers.get_site_config_specs(),
|
||||
*handlers.get_site_layout_specs(),
|
||||
*handlers.get_database_specs(),
|
||||
*handlers.get_bulk_specs(),
|
||||
]
|
||||
|
||||
async def health_check(self) -> dict[str, Any]:
|
||||
"""Probe the companion's admin namespace via the cheapest route.
|
||||
|
||||
``GET /admin/maintenance`` is the smallest payload that exercises
|
||||
the same auth + capability path the rest of the surface uses.
|
||||
Failure surfaces actionable hints: missing companion, missing
|
||||
manage_options, or unreachable site.
|
||||
"""
|
||||
try:
|
||||
payload = await self.management.wp_maintenance_status()
|
||||
return {
|
||||
"healthy": True,
|
||||
"companion": True,
|
||||
"admin_namespace": True,
|
||||
"maintenance_enabled": bool(payload.get("enabled", False)),
|
||||
}
|
||||
except Exception as exc: # pragma: no cover — surfaced to dashboard
|
||||
return {
|
||||
"healthy": False,
|
||||
"companion": False,
|
||||
"error": str(exc),
|
||||
"hint": (
|
||||
"Install Airano MCP Bridge v2.11.0+ on this WordPress "
|
||||
"site and ensure the Application Password belongs to a "
|
||||
"user with manage_options."
|
||||
),
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Method delegation — one per tool spec, mirrored to handlers
|
||||
# ----------------------------------------------------------
|
||||
|
||||
async def wp_plugin_list(self, **kwargs):
|
||||
result = await self.management.wp_plugin_list(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_theme_list(self, **kwargs):
|
||||
result = await self.management.wp_theme_list(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_user_list(self, **kwargs):
|
||||
result = await self.management.wp_user_list(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_option_get(self, **kwargs):
|
||||
result = await self.management.wp_option_get(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_cron_list(self, **kwargs):
|
||||
result = await self.management.wp_cron_list(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_maintenance_status(self, **kwargs):
|
||||
result = await self.management.wp_maintenance_status(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
# F.19.3.1 — system ports (companion v2.12.0+)
|
||||
async def wp_system_info(self, **kwargs):
|
||||
result = await self.management.wp_system_info(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_php_info(self, **kwargs):
|
||||
result = await self.management.wp_php_info(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_disk_usage(self, **kwargs):
|
||||
result = await self.management.wp_disk_usage(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
# F.19.5 — Page editing (Gutenberg blocks + Elementor + Classic, companion v2.13.0+)
|
||||
async def wp_blocks_get(self, **kwargs):
|
||||
result = await self.pages.wp_blocks_get(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_blocks_replace(self, **kwargs):
|
||||
result = await self.pages.wp_blocks_replace(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_blocks_insert_at(self, **kwargs):
|
||||
result = await self.pages.wp_blocks_insert_at(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_blocks_remove_at(self, **kwargs):
|
||||
result = await self.pages.wp_blocks_remove_at(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_elementor_detect(self, **kwargs):
|
||||
result = await self.pages.wp_elementor_detect(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_elementor_get(self, **kwargs):
|
||||
result = await self.pages.wp_elementor_get(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_elementor_set(self, **kwargs):
|
||||
result = await self.pages.wp_elementor_set(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_elementor_render_css(self, **kwargs):
|
||||
result = await self.pages.wp_elementor_render_css(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_elementor_template_list(self, **kwargs):
|
||||
result = await self.pages.wp_elementor_template_list(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_elementor_template_apply(self, **kwargs):
|
||||
result = await self.pages.wp_elementor_template_apply(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_classic_html_replace(self, **kwargs):
|
||||
result = await self.pages.wp_classic_html_replace(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
# F.19.7 — Theme dev surface (install + file CRUD, companion v2.14.0+)
|
||||
async def wp_theme_install_from_zip(self, **kwargs):
|
||||
result = await self.themes.wp_theme_install_from_zip(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_theme_activate(self, **kwargs):
|
||||
result = await self.themes.wp_theme_activate(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_theme_delete(self, **kwargs):
|
||||
result = await self.themes.wp_theme_delete(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_theme_file_list(self, **kwargs):
|
||||
result = await self.themes.wp_theme_file_list(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_theme_file_read(self, **kwargs):
|
||||
result = await self.themes.wp_theme_file_read(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_theme_file_write(self, **kwargs):
|
||||
result = await self.themes.wp_theme_file_write(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_theme_file_delete(self, **kwargs):
|
||||
result = await self.themes.wp_theme_file_delete(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
# F.19.2.1 — Plugin write management (install + admin, companion v2.15.0+)
|
||||
async def wp_plugin_install_from_slug(self, **kwargs):
|
||||
result = await self.plugins.wp_plugin_install_from_slug(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_plugin_install_from_zip(self, **kwargs):
|
||||
result = await self.plugins.wp_plugin_install_from_zip(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_plugin_activate(self, **kwargs):
|
||||
result = await self.plugins.wp_plugin_activate(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_plugin_deactivate(self, **kwargs):
|
||||
result = await self.plugins.wp_plugin_deactivate(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_plugin_update(self, **kwargs):
|
||||
result = await self.plugins.wp_plugin_update(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_plugin_delete(self, **kwargs):
|
||||
result = await self.plugins.wp_plugin_delete(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
# F.19.6.A — Site config (identity + reading + permalinks, companion v2.16.0+)
|
||||
async def wp_site_identity_get(self, **kwargs):
|
||||
result = await self.site_config.wp_site_identity_get(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_site_identity_set(self, **kwargs):
|
||||
result = await self.site_config.wp_site_identity_set(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_reading_settings_get(self, **kwargs):
|
||||
result = await self.site_config.wp_reading_settings_get(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_reading_settings_set(self, **kwargs):
|
||||
result = await self.site_config.wp_reading_settings_set(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_permalinks_get(self, **kwargs):
|
||||
result = await self.site_config.wp_permalinks_get(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_permalinks_set(self, **kwargs):
|
||||
result = await self.site_config.wp_permalinks_set(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
# F.19.6.B — Site layout (menus + widgets + customizer, companion v2.17.0+)
|
||||
async def wp_menu_list(self, **kwargs):
|
||||
result = await self.site_layout.wp_menu_list(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_menu_get(self, **kwargs):
|
||||
result = await self.site_layout.wp_menu_get(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_menu_set(self, **kwargs):
|
||||
result = await self.site_layout.wp_menu_set(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_widget_areas_list(self, **kwargs):
|
||||
result = await self.site_layout.wp_widget_areas_list(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_widget_get(self, **kwargs):
|
||||
result = await self.site_layout.wp_widget_get(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_widget_set(self, **kwargs):
|
||||
result = await self.site_layout.wp_widget_set(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_customizer_changeset(self, **kwargs):
|
||||
result = await self.site_layout.wp_customizer_changeset(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
# F.19.3.2 — Database inspection (read-only, companion v2.18.0+)
|
||||
async def wp_db_size(self, **kwargs):
|
||||
result = await self.database.wp_db_size(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_db_tables(self, **kwargs):
|
||||
result = await self.database.wp_db_tables(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_db_search(self, **kwargs):
|
||||
result = await self.database.wp_db_search(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
# F.19.3.3 — Bulk fan-out (editor tier, stock REST batch)
|
||||
async def wp_bulk_post_update(self, **kwargs):
|
||||
result = await self.bulk.wp_bulk_post_update(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_bulk_term_update(self, **kwargs):
|
||||
result = await self.bulk.wp_bulk_term_update(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
Reference in New Issue
Block a user