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:
2026-05-20 23:33:20 +02:00
parent f203ca88de
commit 43fd2201a0
223 changed files with 36183 additions and 4115 deletions

View 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