chore(F.4e): remove env-based site loading, unify OAuth Clients, update docs
- Remove env-based site discovery from SiteManager and ProjectManager - Unify admin and user OAuth Clients into single role-based page - Update all documentation to reflect DB-based site management - Clean up env.example, README, ARCHITECTURE docs - Improve Projects page empty state with link to My Sites - Remove obsolete env discovery tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,9 +5,12 @@
|
||||
# Version: 3.0.0
|
||||
# Last Updated: 2026-02-17
|
||||
#
|
||||
# This file contains all environment variables needed to run the MCP server.
|
||||
# This file contains environment variables needed to run the MCP server.
|
||||
# Copy this file to .env and fill in your actual values.
|
||||
#
|
||||
# SITES: Sites are managed via the web dashboard (DB-based), not env vars.
|
||||
# The plugin sections below are kept as credential reference only.
|
||||
#
|
||||
# SECURITY NOTE: Never commit .env file to version control!
|
||||
#
|
||||
# Multi-Endpoint Architecture (v3.0.0):
|
||||
|
||||
29
CLAUDE.md
29
CLAUDE.md
@@ -125,26 +125,17 @@ plugins/{name}/
|
||||
|
||||
### Tool Generation
|
||||
|
||||
Tools are dynamically generated at startup:
|
||||
1. `SiteManager` discovers sites from env vars (`{PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}`)
|
||||
2. `ToolGenerator` creates unified tools with a `site` parameter injected
|
||||
3. Tools are registered in `ToolRegistry` and exposed via FastMCP
|
||||
Tools are dynamically generated at startup from plugin specifications:
|
||||
1. `ToolGenerator` creates unified tools with a `site` parameter injected
|
||||
2. Tools are registered in `ToolRegistry` and exposed via FastMCP
|
||||
|
||||
Unified tool pattern: `wordpress_create_post(site="myblog", title="Hello")` — the `site` parameter accepts either a site_id or alias.
|
||||
|
||||
### Site Configuration via Environment Variables
|
||||
### Site Configuration
|
||||
|
||||
Pattern: `{PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}`
|
||||
|
||||
```bash
|
||||
WORDPRESS_SITE1_URL=https://example.com
|
||||
WORDPRESS_SITE1_USERNAME=admin
|
||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
||||
WORDPRESS_SITE1_ALIAS=myblog # optional friendly name
|
||||
WORDPRESS_SITE1_CONTAINER=wp-docker # optional, for WP-CLI
|
||||
```
|
||||
|
||||
Parsed by `core/site_manager.py` into `SiteConfig` (Pydantic model). Sites are auto-discovered on startup.
|
||||
Sites are managed via the web dashboard and stored in SQLite (DB-based).
|
||||
User sites are encrypted with AES-256-GCM and accessed via `core/site_api.py` + `core/database.py`.
|
||||
`SiteManager` (`core/site_manager.py`) provides registration and lookup infrastructure for tool generation.
|
||||
|
||||
### Key Core Modules
|
||||
|
||||
@@ -152,7 +143,7 @@ Parsed by `core/site_manager.py` into `SiteConfig` (Pydantic model). Sites are a
|
||||
|--------|---------|
|
||||
| `core/auth.py` | Master API key validation, request authentication |
|
||||
| `core/api_keys.py` | Per-project API keys with scopes (read/write/admin) |
|
||||
| `core/site_manager.py` | Type-safe site config discovery from env vars |
|
||||
| `core/site_manager.py` | Type-safe site config registration and lookup |
|
||||
| `core/tool_registry.py` | Central tool definitions and lookup |
|
||||
| `core/tool_generator.py` | Dynamic unified tool creation with site injection |
|
||||
| `core/health.py` | Health monitoring, metrics, alerts |
|
||||
@@ -182,7 +173,7 @@ Web UI at the server root, built with Starlette + Jinja2 + HTMX + Tailwind CSS.
|
||||
|
||||
### Legacy Modules (Deprecated)
|
||||
|
||||
`core/project_manager.py`, `core/site_registry.py`, `core/unified_tools.py` — kept for backward compatibility. New code should use `SiteManager`, `ToolRegistry`, and `ToolGenerator` instead.
|
||||
`core/project_manager.py` (retained for HealthMonitor compatibility), `core/site_registry.py`, `core/unified_tools.py` — kept for backward compatibility. New code should use `SiteManager`, `ToolRegistry`, and `ToolGenerator` instead.
|
||||
|
||||
## Commit Style
|
||||
|
||||
@@ -211,7 +202,7 @@ v4 development cycle with 15 phases. See `docs/ROADMAP.md` (Track F) and `.claud
|
||||
- `env.example` has "FUTURE" labels for Supabase/Gitea but both are fully implemented
|
||||
- 4 plugins are tested for public use: WordPress, WooCommerce, Supabase, OpenPanel. Others are admin-only or disabled.
|
||||
- OAuth Clients (Client ID/Secret) are for MCP endpoint auth, NOT the same as GitHub/Google dashboard login
|
||||
- User sites stored in SQLite (`core/database.py`), admin sites still from env vars
|
||||
- All sites stored in SQLite (`core/database.py`), managed via web dashboard
|
||||
- Dashboard templates live in `core/templates/` (included in pip package as `package_data`)
|
||||
- `ruff` config uses top-level `select` key in pyproject.toml (not `[tool.ruff.lint]` nested format)
|
||||
- The `scripts/` directory has platform-specific setup scripts: `setup.sh` (Linux/Mac), `setup.ps1` (Windows)
|
||||
|
||||
71
README.md
71
README.md
@@ -125,28 +125,15 @@ Your personal MCP endpoint: `https://mcp.example.com/u/{your-user-id}/{alias}/mc
|
||||
|
||||
### Configure Your Sites
|
||||
|
||||
Add site credentials to `.env`:
|
||||
Sites are managed via the **web dashboard** — no environment variables needed.
|
||||
|
||||
1. Set `MASTER_API_KEY` in your `.env` file
|
||||
2. Start the server and open the dashboard
|
||||
3. Add sites with their credentials (URL, username, password/token)
|
||||
|
||||
```bash
|
||||
# Master API Key (recommended — auto-generates temp key if omitted)
|
||||
# .env — only system configuration needed
|
||||
MASTER_API_KEY=your-secure-key-here
|
||||
|
||||
# WordPress Site
|
||||
WORDPRESS_SITE1_URL=https://myblog.com
|
||||
WORDPRESS_SITE1_USERNAME=admin
|
||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
||||
WORDPRESS_SITE1_ALIAS=myblog
|
||||
|
||||
# WooCommerce Store
|
||||
WOOCOMMERCE_STORE1_URL=https://mystore.com
|
||||
WOOCOMMERCE_STORE1_CONSUMER_KEY=ck_xxxxx
|
||||
WOOCOMMERCE_STORE1_CONSUMER_SECRET=cs_xxxxx
|
||||
WOOCOMMERCE_STORE1_ALIAS=mystore
|
||||
|
||||
# Gitea Instance
|
||||
GITEA_REPO1_URL=https://git.example.com
|
||||
GITEA_REPO1_TOKEN=your_gitea_token
|
||||
GITEA_REPO1_ALIAS=mygitea
|
||||
```
|
||||
|
||||
<details>
|
||||
@@ -158,45 +145,25 @@ GITEA_REPO1_ALIAS=mygitea
|
||||
|----------|----------|---------|-------------|
|
||||
| `MASTER_API_KEY` | Recommended | Auto-generated | Master API key for admin access |
|
||||
| `LOG_LEVEL` | No | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) |
|
||||
| `ENCRYPTION_KEY` | For Live Platform | — | AES-256-GCM key for credential encryption |
|
||||
| `OAUTH_JWT_SECRET_KEY` | For OAuth | — | JWT secret for ChatGPT auto-registration (not needed for Claude/Cursor) |
|
||||
| `OAUTH_BASE_URL` | For OAuth | — | Public URL of your server (not needed for Claude/Cursor) |
|
||||
| `OAUTH_JWT_ALGORITHM` | No | `HS256` | JWT algorithm |
|
||||
| `OAUTH_ACCESS_TOKEN_TTL` | No | `3600` | Access token TTL in seconds |
|
||||
| `OAUTH_REFRESH_TOKEN_TTL` | No | `604800` | Refresh token TTL in seconds |
|
||||
| `OAUTH_STORAGE_TYPE` | No | `json` | Token storage type |
|
||||
| `OAUTH_STORAGE_PATH` | No | `/app/data` | Data directory path |
|
||||
|
||||
> **OAuth** is only needed for ChatGPT Remote MCP auto-registration. For Claude Desktop, Claude Code, Cursor, and VS Code — just use `MASTER_API_KEY` with Bearer token auth.
|
||||
|
||||
**Plugin Site Configuration** — Pattern: `{PLUGIN_TYPE}_{SITE_ID}_{KEY}`
|
||||
**Plugin Credential Reference** — when adding sites via dashboard, you'll need:
|
||||
|
||||
| Plugin | Required Keys | Optional Keys |
|
||||
|--------|--------------|---------------|
|
||||
| `WORDPRESS` | `URL`, `USERNAME`, `APP_PASSWORD` | `ALIAS`, `CONTAINER` |
|
||||
| `WOOCOMMERCE` | `URL`, `CONSUMER_KEY`, `CONSUMER_SECRET` | `ALIAS` |
|
||||
| `WORDPRESS_ADVANCED` | `URL`, `USERNAME`, `APP_PASSWORD`, `CONTAINER` | `ALIAS` |
|
||||
| `GITEA` | `URL`, `TOKEN` | `ALIAS` |
|
||||
| `N8N` | `URL`, `API_KEY` | `ALIAS` |
|
||||
| `SUPABASE` | `URL`, `SERVICE_ROLE_KEY` | `ALIAS` |
|
||||
| `OPENPANEL` | `URL`, `CLIENT_ID`, `CLIENT_SECRET` | `ALIAS` |
|
||||
| `APPWRITE` | `URL`, `API_KEY`, `PROJECT_ID` | `ALIAS` |
|
||||
| `DIRECTUS` | `URL`, `TOKEN` | `ALIAS` |
|
||||
|
||||
> **CONTAINER**: Docker container name of your WordPress site. Optional for WordPress (enables WP-CLI tools like cache flush, transient management). **Required** for WordPress Advanced (all 22 tools use WP-CLI). Find your container: `docker ps --filter name=wordpress`. Also requires Docker socket mount.
|
||||
|
||||
**Example** — Multiple WordPress sites:
|
||||
|
||||
```bash
|
||||
WORDPRESS_BLOG_URL=https://blog.example.com
|
||||
WORDPRESS_BLOG_USERNAME=admin
|
||||
WORDPRESS_BLOG_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
||||
WORDPRESS_BLOG_ALIAS=blog
|
||||
|
||||
WORDPRESS_SHOP_URL=https://shop.example.com
|
||||
WORDPRESS_SHOP_USERNAME=admin
|
||||
WORDPRESS_SHOP_APP_PASSWORD=yyyy yyyy yyyy yyyy
|
||||
WORDPRESS_SHOP_ALIAS=shop
|
||||
```
|
||||
| Plugin | Required Credentials | Notes |
|
||||
|--------|---------------------|-------|
|
||||
| WordPress | URL, Username, App Password | [How to create App Password](https://wordpress.org/documentation/article/application-passwords/) |
|
||||
| WooCommerce | URL, Consumer Key, Consumer Secret | WooCommerce → Settings → Advanced → REST API |
|
||||
| WordPress Advanced | URL, Username, App Password, Container | Container = Docker container name (for WP-CLI) |
|
||||
| Gitea | URL, Token | Settings → Applications → Personal Access Token |
|
||||
| n8n | URL, API Key | Settings → API → Create API Key |
|
||||
| Supabase | URL, Service Role Key | Supabase Dashboard → Settings → API |
|
||||
| OpenPanel | URL, Client ID, Client Secret | OpenPanel Dashboard → Project Settings |
|
||||
| Appwrite | URL, API Key, Project ID | Appwrite Console → Settings → API Keys |
|
||||
| Directus | URL, Static Token | Directus Admin → Settings |
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
|
||||
import bcrypt
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import bcrypt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -850,12 +850,9 @@ async def get_all_projects(
|
||||
is_admin = False
|
||||
current_user_id = None
|
||||
if user_session:
|
||||
if (
|
||||
hasattr(user_session, "user_type")
|
||||
and user_session.user_type == "master"
|
||||
or isinstance(user_session, dict)
|
||||
and user_session.get("role") == "admin"
|
||||
):
|
||||
if hasattr(user_session, "user_type") and user_session.user_type == "master":
|
||||
is_admin = True
|
||||
elif isinstance(user_session, dict) and user_session.get("role") == "admin":
|
||||
is_admin = True
|
||||
elif isinstance(user_session, dict) and "user_id" in user_session:
|
||||
current_user_id = user_session["user_id"]
|
||||
@@ -1512,6 +1509,7 @@ async def get_oauth_clients_data() -> dict:
|
||||
"grant_types": client.grant_types,
|
||||
"allowed_scopes": client.allowed_scopes,
|
||||
"created_at": client.created_at.isoformat() if client.created_at else "",
|
||||
"owner_user_id": getattr(client, "owner_user_id", None),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1528,10 +1526,26 @@ async def get_oauth_clients_data() -> dict:
|
||||
|
||||
|
||||
async def dashboard_oauth_clients_list(request: Request) -> Response:
|
||||
"""Render OAuth clients list page (admin only)."""
|
||||
session, redirect = _require_admin_session(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
"""Render OAuth clients list page (admin and user)."""
|
||||
# Try admin session first, then user session
|
||||
auth = get_dashboard_auth()
|
||||
session = None
|
||||
is_admin = False
|
||||
user_id = None
|
||||
|
||||
admin_session = auth.get_session_from_request(request)
|
||||
if admin_session and is_admin_session(admin_session):
|
||||
session = admin_session
|
||||
is_admin = True
|
||||
else:
|
||||
user_session = auth.get_user_session_from_request(request)
|
||||
if user_session:
|
||||
session = user_session
|
||||
is_admin = is_admin_session(user_session)
|
||||
user_id = user_session.get("user_id")
|
||||
|
||||
if not session:
|
||||
return RedirectResponse(url="/auth/login", status_code=303)
|
||||
|
||||
# Get language
|
||||
accept_language = request.headers.get("accept-language")
|
||||
@@ -1539,8 +1553,13 @@ async def dashboard_oauth_clients_list(request: Request) -> Response:
|
||||
lang = detect_language(accept_language, query_lang)
|
||||
t = get_translations(lang)
|
||||
|
||||
# Get clients data
|
||||
# Get clients data — admin sees all, user sees own
|
||||
clients_data = await get_oauth_clients_data()
|
||||
if not is_admin and user_id:
|
||||
clients_data["clients"] = [
|
||||
c for c in clients_data["clients"] if c.get("owner_user_id") == user_id
|
||||
]
|
||||
clients_data["total_count"] = len(clients_data["clients"])
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -1552,15 +1571,26 @@ async def dashboard_oauth_clients_list(request: Request) -> Response:
|
||||
"clients": clients_data["clients"],
|
||||
"total_count": clients_data["total_count"],
|
||||
"current_page": "oauth_clients",
|
||||
"is_admin": is_admin,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def dashboard_oauth_clients_create(request: Request) -> Response:
|
||||
"""API endpoint to create OAuth client (admin only)."""
|
||||
session, redirect = _require_admin_session(request)
|
||||
if redirect:
|
||||
return JSONResponse({"error": "Admin access required"}, status_code=403)
|
||||
"""API endpoint to create OAuth client (admin and user)."""
|
||||
# Accept both admin and user sessions
|
||||
auth = get_dashboard_auth()
|
||||
owner_user_id = None
|
||||
|
||||
admin_session = auth.get_session_from_request(request)
|
||||
user_session = auth.get_user_session_from_request(request)
|
||||
|
||||
if admin_session and is_admin_session(admin_session):
|
||||
pass # Admin — no owner_user_id
|
||||
elif user_session:
|
||||
owner_user_id = user_session.get("user_id")
|
||||
else:
|
||||
return JSONResponse({"error": "Authentication required"}, status_code=403)
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
@@ -1569,7 +1599,7 @@ async def dashboard_oauth_clients_create(request: Request) -> Response:
|
||||
redirect_uris = data.get("redirect_uris") or []
|
||||
if not redirect_uris and data.get("redirect_uri"):
|
||||
redirect_uris = [data.get("redirect_uri")]
|
||||
scopes = data.get("scopes", ["read"])
|
||||
scopes = data.get("scopes", ["read", "write", "admin"])
|
||||
|
||||
if not client_name or not redirect_uris:
|
||||
return JSONResponse({"error": "Missing required fields"}, status_code=400)
|
||||
@@ -1578,16 +1608,23 @@ async def dashboard_oauth_clients_create(request: Request) -> Response:
|
||||
|
||||
client_registry = get_client_registry()
|
||||
|
||||
client_id, client_secret = client_registry.create_client(
|
||||
client_name=client_name, redirect_uris=redirect_uris, allowed_scopes=scopes
|
||||
)
|
||||
create_kwargs = {
|
||||
"client_name": client_name,
|
||||
"redirect_uris": redirect_uris,
|
||||
"allowed_scopes": scopes,
|
||||
}
|
||||
if owner_user_id:
|
||||
create_kwargs["owner_user_id"] = owner_user_id
|
||||
|
||||
client_id, client_secret = client_registry.create_client(**create_kwargs)
|
||||
|
||||
# Log the action
|
||||
from core.audit_log import get_audit_logger
|
||||
|
||||
audit_logger = get_audit_logger()
|
||||
audit_logger.log_system_event(
|
||||
event=f"OAuth client created: {client_name}", details={"client_id": client_id}
|
||||
event=f"OAuth client created: {client_name}",
|
||||
details={"client_id": client_id, "owner_user_id": owner_user_id},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
@@ -1604,10 +1641,22 @@ async def dashboard_oauth_clients_create(request: Request) -> Response:
|
||||
|
||||
|
||||
async def dashboard_oauth_clients_delete(request: Request) -> Response:
|
||||
"""API endpoint to delete OAuth client (admin only)."""
|
||||
session, redirect = _require_admin_session(request)
|
||||
if redirect:
|
||||
return JSONResponse({"error": "Admin access required"}, status_code=403)
|
||||
"""API endpoint to delete OAuth client (admin and user)."""
|
||||
# Accept both admin and user sessions
|
||||
auth = get_dashboard_auth()
|
||||
is_admin_user = False
|
||||
user_id = None
|
||||
|
||||
admin_session = auth.get_session_from_request(request)
|
||||
user_session = auth.get_user_session_from_request(request)
|
||||
|
||||
if admin_session and is_admin_session(admin_session):
|
||||
is_admin_user = True
|
||||
elif user_session:
|
||||
user_id = user_session.get("user_id")
|
||||
is_admin_user = is_admin_session(user_session)
|
||||
else:
|
||||
return JSONResponse({"error": "Authentication required"}, status_code=403)
|
||||
|
||||
try:
|
||||
client_id = request.path_params.get("client_id", "")
|
||||
@@ -1616,6 +1665,14 @@ async def dashboard_oauth_clients_delete(request: Request) -> Response:
|
||||
|
||||
client_registry = get_client_registry()
|
||||
|
||||
# Non-admin users can only delete their own clients
|
||||
if not is_admin_user and user_id:
|
||||
client = client_registry.get_client(client_id)
|
||||
if not client:
|
||||
return JSONResponse({"error": "Client not found"}, status_code=404)
|
||||
if getattr(client, "owner_user_id", None) != user_id:
|
||||
return JSONResponse({"error": "Access denied"}, status_code=403)
|
||||
|
||||
success = client_registry.delete_client(client_id)
|
||||
|
||||
if success:
|
||||
@@ -3131,107 +3188,18 @@ async def api_get_config(request: Request) -> Response:
|
||||
|
||||
|
||||
async def dashboard_user_oauth_clients_list(request: Request) -> Response:
|
||||
"""GET /dashboard/connect/oauth-clients — OAuth user's own OAuth clients."""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
|
||||
accept_language = request.headers.get("accept-language")
|
||||
query_lang = request.query_params.get("lang")
|
||||
lang = detect_language(accept_language, query_lang)
|
||||
t = get_translations(lang)
|
||||
|
||||
from core.oauth import get_client_registry as _get_client_registry
|
||||
|
||||
registry = _get_client_registry()
|
||||
user_id = user_session["user_id"]
|
||||
user_clients = [c for c in registry.list_clients() if c.owner_user_id == user_id]
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard/user-oauth-clients.html",
|
||||
{
|
||||
"lang": lang,
|
||||
"t": t,
|
||||
"session": user_session,
|
||||
"clients": user_clients,
|
||||
"current_page": "connect",
|
||||
},
|
||||
)
|
||||
"""GET /dashboard/connect/oauth-clients — Redirect to unified OAuth clients page."""
|
||||
return RedirectResponse(url="/dashboard/oauth-clients", status_code=303)
|
||||
|
||||
|
||||
async def dashboard_user_oauth_clients_create(request: Request) -> Response:
|
||||
"""POST /api/dashboard/user-oauth-clients/create — Create OAuth client for OAuth user."""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
if redirect:
|
||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
|
||||
client_name = body.get("client_name", "").strip()
|
||||
redirect_uris_raw = body.get("redirect_uris", "")
|
||||
scopes = body.get("scopes", ["read", "write", "admin"])
|
||||
|
||||
if not client_name:
|
||||
return JSONResponse({"error": "Client name required"}, status_code=400)
|
||||
|
||||
if isinstance(redirect_uris_raw, str):
|
||||
redirect_uris = [u.strip() for u in redirect_uris_raw.splitlines() if u.strip()]
|
||||
else:
|
||||
redirect_uris = [u.strip() for u in redirect_uris_raw if u.strip()]
|
||||
|
||||
if not redirect_uris:
|
||||
return JSONResponse({"error": "At least one redirect URI required"}, status_code=400)
|
||||
|
||||
scope_str = " ".join(scopes) if isinstance(scopes, list) else scopes
|
||||
|
||||
from core.oauth import get_client_registry as _get_client_registry
|
||||
|
||||
registry = _get_client_registry()
|
||||
client_id, client_secret = registry.create_client(
|
||||
client_name=client_name,
|
||||
redirect_uris=redirect_uris,
|
||||
allowed_scopes=scope_str.split() if isinstance(scope_str, str) else scopes,
|
||||
owner_user_id=user_session["user_id"],
|
||||
)
|
||||
client = registry.get_client(client_id)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"client_name": client.client_name,
|
||||
"redirect_uris": client.redirect_uris,
|
||||
"scope": client.scope,
|
||||
}
|
||||
)
|
||||
"""POST /api/dashboard/user-oauth-clients/create — Forwards to unified create endpoint."""
|
||||
return await dashboard_oauth_clients_create(request)
|
||||
|
||||
|
||||
async def dashboard_user_oauth_clients_delete(request: Request) -> Response:
|
||||
"""DELETE /api/dashboard/user-oauth-clients/{client_id} — Delete user's own OAuth client."""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
if redirect:
|
||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
|
||||
client_id = request.path_params.get("client_id", "")
|
||||
|
||||
from core.oauth import get_client_registry as _get_client_registry
|
||||
|
||||
registry = _get_client_registry()
|
||||
client = registry.get_client(client_id)
|
||||
|
||||
if not client:
|
||||
return JSONResponse({"error": "Client not found"}, status_code=404)
|
||||
|
||||
# Only allow deleting own clients
|
||||
if client.owner_user_id != user_session["user_id"]:
|
||||
return JSONResponse({"error": "Access denied"}, status_code=403)
|
||||
|
||||
registry.delete_client(client_id)
|
||||
return JSONResponse({"success": True})
|
||||
"""DELETE /api/dashboard/user-oauth-clients/{client_id} — Forwards to unified delete endpoint."""
|
||||
return await dashboard_oauth_clients_delete(request)
|
||||
|
||||
|
||||
async def get_service_page_data(plugin_type: str) -> dict | None:
|
||||
|
||||
@@ -1,33 +1,23 @@
|
||||
"""
|
||||
Project Manager
|
||||
Project Manager (Legacy)
|
||||
|
||||
Discovers and manages project instances from environment variables.
|
||||
Handles plugin lifecycle and tool registration.
|
||||
Retained for backward compatibility with HealthMonitor.
|
||||
Sites are now managed via the web dashboard (DB-based).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from plugins import BasePlugin, registry
|
||||
from plugins import BasePlugin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProjectManager:
|
||||
"""
|
||||
Manage multiple project instances.
|
||||
Legacy project manager — retained for HealthMonitor compatibility.
|
||||
|
||||
Projects are discovered from environment variables:
|
||||
- {PLUGIN_TYPE}_{PROJECT_ID}_{CONFIG_KEY}
|
||||
|
||||
Example:
|
||||
WORDPRESS_SITE1_URL=https://example.com
|
||||
WORDPRESS_SITE1_USERNAME=admin
|
||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx
|
||||
WORDPRESS_SITE2_URL=https://other.com
|
||||
...
|
||||
Sites are now managed via the web dashboard and stored in SQLite.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
@@ -35,115 +25,6 @@ class ProjectManager:
|
||||
self.projects: dict[str, BasePlugin] = {}
|
||||
self.logger = logging.getLogger("ProjectManager")
|
||||
|
||||
def discover_projects(self) -> None:
|
||||
"""
|
||||
Discover projects from environment variables.
|
||||
|
||||
Scans environment for project configurations and creates
|
||||
plugin instances.
|
||||
"""
|
||||
self.logger.info("Starting project discovery...")
|
||||
|
||||
# Get all registered plugin types
|
||||
plugin_types = registry.get_registered_types()
|
||||
|
||||
for plugin_type in plugin_types:
|
||||
self._discover_plugin_type(plugin_type)
|
||||
|
||||
self.logger.info(f"Discovery complete. Found {len(self.projects)} projects.")
|
||||
|
||||
def _discover_plugin_type(self, plugin_type: str) -> None:
|
||||
"""
|
||||
Discover all projects of a specific plugin type.
|
||||
|
||||
Args:
|
||||
plugin_type: Type of plugin (e.g., 'wordpress')
|
||||
"""
|
||||
prefix = plugin_type.upper() + "_"
|
||||
|
||||
# Build list of longer prefixes from other plugin types to avoid collisions.
|
||||
# e.g. WORDPRESS_ must not match WORDPRESS_ADVANCED_ env vars.
|
||||
plugin_types = registry.get_registered_types()
|
||||
longer_prefixes = [
|
||||
pt.upper() + "_"
|
||||
for pt in plugin_types
|
||||
if pt != plugin_type and pt.upper().startswith(plugin_type.upper() + "_")
|
||||
]
|
||||
|
||||
# Find all project IDs for this plugin type
|
||||
project_ids = set()
|
||||
env_pattern = re.compile(f"^{prefix}([A-Z0-9_]+?)_(.+)$")
|
||||
|
||||
for env_key in os.environ.keys():
|
||||
# Skip env vars that belong to a more specific plugin type
|
||||
if any(env_key.startswith(lp) for lp in longer_prefixes):
|
||||
continue
|
||||
|
||||
match = env_pattern.match(env_key)
|
||||
if match:
|
||||
project_id = match.group(1).lower()
|
||||
project_ids.add(project_id)
|
||||
|
||||
# Create plugin instance for each project
|
||||
for project_id in project_ids:
|
||||
try:
|
||||
config = self._load_project_config(plugin_type, project_id)
|
||||
if config:
|
||||
self._create_project_instance(plugin_type, project_id, config)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Legacy ProjectManager: skipped {plugin_type}/{project_id}: {e}")
|
||||
|
||||
def _load_project_config(self, plugin_type: str, project_id: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Load configuration for a project from environment.
|
||||
|
||||
Args:
|
||||
plugin_type: Plugin type
|
||||
project_id: Project ID
|
||||
|
||||
Returns:
|
||||
Dict with configuration or None if incomplete
|
||||
"""
|
||||
prefix = f"{plugin_type.upper()}_{project_id.upper()}_"
|
||||
config = {}
|
||||
|
||||
# Collect all config keys for this project
|
||||
for env_key, env_value in os.environ.items():
|
||||
if env_key.startswith(prefix):
|
||||
# Extract config key (everything after prefix)
|
||||
config_key = env_key[len(prefix) :].lower()
|
||||
config[config_key] = env_value
|
||||
|
||||
if not config:
|
||||
return None
|
||||
|
||||
self.logger.debug(f"Loaded config for {plugin_type}/{project_id}: {list(config.keys())}")
|
||||
return config
|
||||
|
||||
def _create_project_instance(
|
||||
self, plugin_type: str, project_id: str, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""
|
||||
Create a plugin instance for a project.
|
||||
|
||||
Args:
|
||||
plugin_type: Plugin type
|
||||
project_id: Project ID
|
||||
config: Project configuration
|
||||
"""
|
||||
try:
|
||||
# Create plugin instance
|
||||
plugin = registry.create_instance(plugin_type, project_id, config)
|
||||
|
||||
# Store with full identifier
|
||||
full_id = f"{plugin_type}_{project_id}"
|
||||
self.projects[full_id] = plugin
|
||||
|
||||
self.logger.info(f"Created project: {full_id}")
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to instantiate {plugin_type}/{project_id}: {e}")
|
||||
|
||||
def get_project(self, full_id: str) -> BasePlugin | None:
|
||||
"""
|
||||
Get a project plugin instance.
|
||||
@@ -255,5 +136,4 @@ def get_project_manager() -> ProjectManager:
|
||||
global _project_manager
|
||||
if _project_manager is None:
|
||||
_project_manager = ProjectManager()
|
||||
_project_manager.discover_projects()
|
||||
return _project_manager
|
||||
|
||||
@@ -4,20 +4,12 @@ Site Manager - Type-safe site configuration management
|
||||
Manages site configurations with Pydantic validation.
|
||||
Part of Option B clean architecture refactoring.
|
||||
|
||||
Discovers sites from environment variables:
|
||||
- {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}
|
||||
- {PLUGIN_TYPE}_{SITE_ID}_ALIAS (optional)
|
||||
|
||||
Example:
|
||||
WORDPRESS_SITE1_URL=https://example.com
|
||||
WORDPRESS_SITE1_USERNAME=admin
|
||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx
|
||||
WORDPRESS_SITE2_ALIAS=myblog
|
||||
Sites are managed via the web dashboard and stored in SQLite (DB-based).
|
||||
The SiteManager provides registration and lookup infrastructure for
|
||||
plugin tool generation and endpoint routing.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
|
||||
@@ -111,7 +103,8 @@ class SiteManager:
|
||||
"""
|
||||
Manage site configurations with type safety.
|
||||
|
||||
Discovers, validates, and provides access to site configurations.
|
||||
Provides registration and lookup of site configurations.
|
||||
Sites are registered programmatically (e.g., from database) via register_site().
|
||||
|
||||
Attributes:
|
||||
sites: Dictionary mapping plugin_type to site configurations
|
||||
@@ -120,7 +113,7 @@ class SiteManager:
|
||||
|
||||
Examples:
|
||||
>>> manager = SiteManager()
|
||||
>>> manager.discover_sites(['wordpress', 'gitea'])
|
||||
>>> manager.register_site(config)
|
||||
>>> config = manager.get_site_config('wordpress', 'myblog')
|
||||
>>> sites = manager.list_sites('wordpress')
|
||||
"""
|
||||
@@ -136,167 +129,6 @@ class SiteManager:
|
||||
self.logger = logging.getLogger("SiteManager")
|
||||
self.logger.info("SiteManager initialized")
|
||||
|
||||
def discover_sites(self, plugin_types: list[str]) -> int:
|
||||
"""
|
||||
Discover sites from environment variables.
|
||||
|
||||
Scans environment for site configurations and registers them.
|
||||
|
||||
Args:
|
||||
plugin_types: List of plugin types to discover (e.g., ['wordpress'])
|
||||
|
||||
Returns:
|
||||
Number of sites discovered
|
||||
|
||||
Examples:
|
||||
>>> count = manager.discover_sites(['wordpress', 'gitea'])
|
||||
>>> print(f"Discovered {count} sites")
|
||||
"""
|
||||
self.logger.info(f"Starting site discovery for: {', '.join(plugin_types)}")
|
||||
|
||||
total_discovered = 0
|
||||
for plugin_type in plugin_types:
|
||||
count = self._discover_plugin_sites(plugin_type)
|
||||
total_discovered += count
|
||||
|
||||
self.logger.info(
|
||||
f"Discovery complete. Found {total_discovered} sites "
|
||||
f"across {len(plugin_types)} plugin types."
|
||||
)
|
||||
|
||||
return total_discovered
|
||||
|
||||
# Reserved words that should NOT be interpreted as site IDs
|
||||
RESERVED_SITE_WORDS = {
|
||||
"limit",
|
||||
"rate",
|
||||
"config",
|
||||
"debug",
|
||||
"log",
|
||||
"level",
|
||||
"mode",
|
||||
"timeout",
|
||||
"retry",
|
||||
"max",
|
||||
"min",
|
||||
"default",
|
||||
"global",
|
||||
"enabled",
|
||||
"disabled",
|
||||
"host",
|
||||
"port",
|
||||
"path",
|
||||
"key",
|
||||
"secret",
|
||||
"token",
|
||||
"advanced",
|
||||
"basic",
|
||||
"simple",
|
||||
"pro",
|
||||
"premium",
|
||||
"standard",
|
||||
}
|
||||
|
||||
def _discover_plugin_sites(self, plugin_type: str) -> int:
|
||||
"""
|
||||
Discover all sites for a specific plugin type.
|
||||
|
||||
Args:
|
||||
plugin_type: Type of plugin (e.g., 'wordpress')
|
||||
|
||||
Returns:
|
||||
Number of sites discovered for this plugin type
|
||||
|
||||
Examples:
|
||||
>>> count = manager._discover_plugin_sites('wordpress')
|
||||
"""
|
||||
prefix = plugin_type.upper() + "_"
|
||||
|
||||
# Build list of longer prefixes from other plugin types to avoid collisions.
|
||||
# e.g. WORDPRESS_ must not match WORDPRESS_ADVANCED_ env vars.
|
||||
from plugins import registry as plugin_registry
|
||||
|
||||
all_plugin_types = plugin_registry.get_registered_types()
|
||||
longer_prefixes = [
|
||||
pt.upper() + "_"
|
||||
for pt in all_plugin_types
|
||||
if pt != plugin_type and pt.upper().startswith(plugin_type.upper() + "_")
|
||||
]
|
||||
|
||||
# Pattern to match: WORDPRESS_SITE1_URL, WORDPRESS_SITE2_USERNAME, etc.
|
||||
env_pattern = re.compile(f"^{prefix}([A-Z0-9_]+?)_(.+)$")
|
||||
|
||||
# Find all unique site IDs
|
||||
site_ids = set()
|
||||
for env_key in os.environ.keys():
|
||||
# Skip env vars that belong to a more specific plugin type
|
||||
if any(env_key.startswith(lp) for lp in longer_prefixes):
|
||||
continue
|
||||
|
||||
match = env_pattern.match(env_key)
|
||||
if match:
|
||||
site_id = match.group(1).lower()
|
||||
# Skip reserved words that are not real site IDs
|
||||
if site_id not in self.RESERVED_SITE_WORDS:
|
||||
site_ids.add(site_id)
|
||||
|
||||
# Load configuration for each site
|
||||
discovered_count = 0
|
||||
for site_id in site_ids:
|
||||
try:
|
||||
config = self._load_site_config(plugin_type, site_id)
|
||||
if config:
|
||||
self.register_site(config)
|
||||
discovered_count += 1
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to load {plugin_type} site '{site_id}': {e}", exc_info=True
|
||||
)
|
||||
|
||||
return discovered_count
|
||||
|
||||
def _load_site_config(self, plugin_type: str, site_id: str) -> SiteConfig | None:
|
||||
"""
|
||||
Load configuration for a site from environment.
|
||||
|
||||
Args:
|
||||
plugin_type: Plugin type
|
||||
site_id: Site ID
|
||||
|
||||
Returns:
|
||||
SiteConfig if successful, None if incomplete
|
||||
|
||||
Examples:
|
||||
>>> config = manager._load_site_config('wordpress', 'site1')
|
||||
"""
|
||||
prefix = f"{plugin_type.upper()}_{site_id.upper()}_"
|
||||
config_data = {"site_id": site_id, "plugin_type": plugin_type}
|
||||
|
||||
# Collect all config keys for this site
|
||||
for env_key, env_value in os.environ.items():
|
||||
if env_key.startswith(prefix):
|
||||
# Extract config key (everything after prefix)
|
||||
config_key = env_key[len(prefix) :].lower()
|
||||
config_data[config_key] = env_value
|
||||
|
||||
# Must have at least some configuration beyond site_id and plugin_type
|
||||
if len(config_data) <= 2:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Create and validate SiteConfig
|
||||
config = SiteConfig(**config_data)
|
||||
|
||||
self.logger.debug(
|
||||
f"Loaded config for {plugin_type}/{site_id}: " f"{list(config_data.keys())}"
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Validation failed for {plugin_type}/{site_id}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def register_site(self, config: SiteConfig) -> None:
|
||||
"""
|
||||
Register a site configuration.
|
||||
@@ -356,7 +188,7 @@ class SiteManager:
|
||||
# SECURITY: Don't reveal available plugin types in multi-tenant environment
|
||||
raise ValueError(
|
||||
f"No sites configured for plugin type: {plugin_type}. "
|
||||
f"Please check your environment variables."
|
||||
f"Please add a site via the dashboard."
|
||||
)
|
||||
|
||||
# Try direct lookup
|
||||
@@ -373,7 +205,7 @@ class SiteManager:
|
||||
)
|
||||
raise ValueError(
|
||||
f"Site '{site}' not configured for {plugin_type}. "
|
||||
f"Please verify the site alias/ID and check environment variables."
|
||||
f"Please verify the site alias/ID in the dashboard."
|
||||
)
|
||||
|
||||
def list_sites(self, plugin_type: str) -> list[str]:
|
||||
@@ -545,7 +377,7 @@ def get_site_manager() -> SiteManager:
|
||||
|
||||
Examples:
|
||||
>>> manager = get_site_manager()
|
||||
>>> manager.discover_sites(['wordpress'])
|
||||
>>> manager.register_site(config)
|
||||
"""
|
||||
global _site_manager
|
||||
if _site_manager is None:
|
||||
|
||||
@@ -138,6 +138,9 @@
|
||||
('services', t.get('services', 'Services'), 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', '/dashboard/services'),
|
||||
('connect', t.get('connect', 'Connect'), 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656
|
||||
5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1', '/dashboard/connect'),
|
||||
('oauth_clients', t.oauth_clients, 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0
|
||||
01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622
|
||||
0-1.042-.133-2.052-.382-3.016z', '/dashboard/oauth-clients'),
|
||||
] %}
|
||||
|
||||
{# ── Admin-only nav items ── #}
|
||||
@@ -146,9 +149,6 @@
|
||||
'/dashboard/projects'),
|
||||
('api_keys', t.api_keys, 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0
|
||||
01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z', '/dashboard/api-keys'),
|
||||
('oauth_clients', t.oauth_clients, 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0
|
||||
01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622
|
||||
0-1.042-.133-2.052-.382-3.016z', '/dashboard/oauth-clients'),
|
||||
('audit_logs', t.audit_logs, 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2
|
||||
2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01',
|
||||
'/dashboard/audit-logs'),
|
||||
|
||||
@@ -159,10 +159,10 @@
|
||||
<p class="text-sm text-green-700 dark:text-green-400">
|
||||
{% if lang == 'fa' %}
|
||||
<strong>نکته:</strong> فقط آدرس URL کافی است. هنگام اتصال میتوانید با <strong>API Key</strong> یا <strong>GitHub/Google</strong> احراز هویت کنید.
|
||||
ساخت <a href="/dashboard/connect/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> اختیاری است.
|
||||
ساخت <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> اختیاری است.
|
||||
{% else %}
|
||||
<strong>Tip:</strong> You only need the URL above. When connecting, you can authenticate with an <strong>API Key</strong> or <strong>GitHub/Google</strong>.
|
||||
Creating an <a href="/dashboard/connect/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
|
||||
Creating an <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -228,6 +228,14 @@
|
||||
<p class="text-gray-500 text-sm mt-2">
|
||||
{% if lang == 'fa' %}فیلترها را پاک کنید{% else %}Try clearing the filters{% endif %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="text-gray-500 text-sm mt-2">
|
||||
{% if lang == 'fa' %}
|
||||
سایتها از طریق <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-primary-400 hover:underline">صفحه سایتها</a> مدیریت میشوند.
|
||||
{% else %}
|
||||
Sites are managed via the <a href="/dashboard/sites" class="text-primary-400 hover:underline">My Sites</a> page.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -50,15 +50,9 @@ services:
|
||||
- USER_RATE_LIMIT_PER_MIN=${USER_RATE_LIMIT_PER_MIN:-30}
|
||||
- USER_RATE_LIMIT_PER_HR=${USER_RATE_LIMIT_PER_HR:-500}
|
||||
|
||||
# === ADMIN-MANAGED SITES (optional) ===
|
||||
# Add WordPress/Gitea/n8n/etc. sites here if you want admin-level access.
|
||||
# Users can add their own sites via the dashboard without these.
|
||||
# Format: {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}
|
||||
#
|
||||
# - WORDPRESS_SITE1_URL=${WORDPRESS_SITE1_URL:-}
|
||||
# - WORDPRESS_SITE1_USERNAME=${WORDPRESS_SITE1_USERNAME:-}
|
||||
# - WORDPRESS_SITE1_APP_PASSWORD=${WORDPRESS_SITE1_APP_PASSWORD:-}
|
||||
# - WORDPRESS_SITE1_ALIAS=${WORDPRESS_SITE1_ALIAS:-}
|
||||
# === SITES ===
|
||||
# Sites are managed via the web dashboard (DB-based).
|
||||
# After deployment, open the dashboard to add sites.
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
|
||||
|
||||
@@ -248,12 +248,11 @@ Without Docker socket:
|
||||
- WordPress Advanced database/system tools are unavailable
|
||||
- All REST API tools (bulk operations, content management) work normally
|
||||
|
||||
### Environment Variable Reference
|
||||
### Site Configuration
|
||||
|
||||
All site configuration follows the pattern: `{PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}=value`
|
||||
Sites are managed via the web dashboard. After starting the server, open the dashboard to add sites.
|
||||
|
||||
- `SITE_ID` can be any alphanumeric identifier (e.g., `SITE1`, `PROD`, `MYBLOG`)
|
||||
- Multiple sites: change `SITE1` to `SITE2`, `SITE3`, etc.
|
||||
Each plugin requires specific credentials (see below for reference).
|
||||
|
||||
#### WordPress
|
||||
```env
|
||||
@@ -392,7 +391,7 @@ docker compose logs -f mcphub
|
||||
| Port 8000 already in use | Change port in docker-compose.yaml: `"8001:8000"` |
|
||||
| Health check shows "unhealthy" | Wait 60 seconds, then check logs for startup errors |
|
||||
| Dashboard login fails | Make sure you're using the `MASTER_API_KEY` value from your `.env` |
|
||||
| Sites not showing up | Restart after adding new env vars: `docker compose restart` |
|
||||
| Sites not showing up | Add sites via the web dashboard, then check the connect page |
|
||||
|
||||
---
|
||||
|
||||
@@ -610,18 +609,16 @@ docker compose up --build -d
|
||||
|
||||
### Step 3: Configure Environment Variables
|
||||
|
||||
Add all required environment variables in Coolify's environment variable UI:
|
||||
Add the required environment variables in Coolify's environment variable UI:
|
||||
|
||||
```
|
||||
MASTER_API_KEY=your-secure-key-here
|
||||
OAUTH_JWT_SECRET_KEY=your-jwt-secret
|
||||
OAUTH_BASE_URL=https://your-domain.com
|
||||
WORDPRESS_SITE1_URL=https://example.com
|
||||
WORDPRESS_SITE1_USERNAME=admin
|
||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
||||
ENCRYPTION_KEY=your-base64-encryption-key
|
||||
```
|
||||
|
||||
The server auto-discovers all `WORDPRESS_*`, `WOOCOMMERCE_*`, `GITEA_*`, and other plugin environment variables at startup.
|
||||
After deployment, add sites via the web dashboard at `https://your-domain.com/dashboard`.
|
||||
|
||||
### Step 4: Configure Health Check
|
||||
|
||||
|
||||
117
env.example
117
env.example
@@ -2,7 +2,7 @@
|
||||
# MCP Hub — Environment Configuration
|
||||
# ===================================
|
||||
#
|
||||
# MINIMUM TO START: Just set MASTER_API_KEY and one site below.
|
||||
# MINIMUM TO START: Set MASTER_API_KEY, then add sites via the web dashboard.
|
||||
# Full docs: https://github.com/airano-ir/mcphub/blob/main/docs/getting-started.md
|
||||
#
|
||||
# After editing, run:
|
||||
@@ -49,117 +49,14 @@ DASHBOARD_SESSION_SECRET=
|
||||
# DISABLE_MASTER_KEY_LOGIN=false
|
||||
|
||||
# ============================================
|
||||
# WORDPRESS SITES
|
||||
# SITES
|
||||
# ============================================
|
||||
# Pattern: WORDPRESS_{SITE_ID}_{CONFIG_KEY}
|
||||
# Add as many sites as you want (SITE1, SITE2, SITE3, ...)
|
||||
# Sites are managed via the web dashboard (DB-based).
|
||||
# After starting the server, open the dashboard to add sites:
|
||||
# http://localhost:8000/dashboard
|
||||
#
|
||||
# How to create a WordPress Application Password:
|
||||
# 1. Log into WordPress admin → Users → Your Profile
|
||||
# 2. Scroll to "Application Passwords" section
|
||||
# 3. Enter name "MCP Hub" → click "Add New"
|
||||
# 4. Copy the generated password (format: xxxx xxxx xxxx xxxx)
|
||||
|
||||
WORDPRESS_SITE1_URL=https://your-site.com
|
||||
WORDPRESS_SITE1_USERNAME=admin
|
||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
||||
WORDPRESS_SITE1_ALIAS=mysite
|
||||
|
||||
# Optional: Docker container name for WP-CLI tools (cache flush, transients, etc.)
|
||||
# Find your container name with: docker ps --filter name=wordpress
|
||||
# This lets MCP Hub run wp-cli commands inside your WordPress container.
|
||||
# WORDPRESS_SITE1_CONTAINER=wordpress-container-name
|
||||
|
||||
# Second site example (uncomment to use):
|
||||
# WORDPRESS_SITE2_URL=https://another-site.com
|
||||
# WORDPRESS_SITE2_USERNAME=admin
|
||||
# WORDPRESS_SITE2_APP_PASSWORD=yyyy yyyy yyyy yyyy
|
||||
# WORDPRESS_SITE2_ALIAS=blog
|
||||
|
||||
# ============================================
|
||||
# WORDPRESS ADVANCED (database, bulk, system)
|
||||
# ============================================
|
||||
# Pattern: WORDPRESS_ADVANCED_{SITE_ID}_{CONFIG_KEY}
|
||||
# Provides database operations, bulk operations, and system management.
|
||||
# REQUIRES Docker socket AND container name — all tools use WP-CLI.
|
||||
#
|
||||
# Uses the same credentials as WordPress, but the CONTAINER key is REQUIRED.
|
||||
# WORDPRESS_ADVANCED_SITE1_URL=https://your-site.com
|
||||
# WORDPRESS_ADVANCED_SITE1_USERNAME=admin
|
||||
# WORDPRESS_ADVANCED_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
||||
# WORDPRESS_ADVANCED_SITE1_CONTAINER=wordpress-container-name
|
||||
# WORDPRESS_ADVANCED_SITE1_ALIAS=mysite-advanced
|
||||
|
||||
# ============================================
|
||||
# WOOCOMMERCE STORES
|
||||
# ============================================
|
||||
# Pattern: WOOCOMMERCE_{STORE_ID}_{CONFIG_KEY}
|
||||
# WooCommerce is a separate plugin — uses Consumer Key/Secret, not App Passwords.
|
||||
#
|
||||
# Create keys: WooCommerce → Settings → Advanced → REST API → Add Key
|
||||
|
||||
# WOOCOMMERCE_STORE1_URL=https://your-store.com
|
||||
# WOOCOMMERCE_STORE1_CONSUMER_KEY=ck_xxxxx
|
||||
# WOOCOMMERCE_STORE1_CONSUMER_SECRET=cs_xxxxx
|
||||
# WOOCOMMERCE_STORE1_ALIAS=mystore
|
||||
|
||||
# ============================================
|
||||
# OTHER PLUGINS (uncomment to use)
|
||||
# ============================================
|
||||
# All plugins auto-discover their environment variables at startup.
|
||||
# Pattern: {PLUGIN_TYPE}_{INSTANCE_ID}_{CONFIG_KEY}
|
||||
|
||||
# --- Gitea ---
|
||||
# GITEA_REPO1_URL=https://git.example.com
|
||||
# GITEA_REPO1_TOKEN=your_gitea_token
|
||||
# GITEA_REPO1_ALIAS=mygitea
|
||||
|
||||
# --- n8n ---
|
||||
# N8N_INSTANCE1_URL=https://n8n.example.com
|
||||
# N8N_INSTANCE1_API_KEY=your_n8n_api_key
|
||||
# N8N_INSTANCE1_ALIAS=myn8n
|
||||
|
||||
# --- Supabase (Self-Hosted or Cloud) ---
|
||||
# Works with both self-hosted Supabase and supabase.com cloud projects.
|
||||
# For cloud: URL is https://xxxx.supabase.co
|
||||
# For self-hosted: URL is your Kong gateway (e.g., http://your-server:8000)
|
||||
#
|
||||
# SERVICE_ROLE_KEY is required (bypasses RLS, full admin access).
|
||||
# ANON_KEY is optional — if omitted, SERVICE_ROLE_KEY is used for all calls.
|
||||
#
|
||||
# Note: postgres-meta tools (list_tables, execute_sql, get_table_schema, etc.)
|
||||
# require the /pg/ Kong route or a direct META_URL. These tools do NOT work
|
||||
# on supabase.com cloud (postgres-meta is internal only on cloud).
|
||||
# PostgREST/Auth/Storage/Functions tools work on both cloud and self-hosted.
|
||||
#
|
||||
# SUPABASE_PROJECT1_URL=https://xxxx.supabase.co
|
||||
# SUPABASE_PROJECT1_SERVICE_ROLE_KEY=your_service_role_key
|
||||
# SUPABASE_PROJECT1_ANON_KEY=your_anon_key
|
||||
# SUPABASE_PROJECT1_ALIAS=mysupabase
|
||||
#
|
||||
# Optional: direct postgres-meta URL for self-hosted when /pg/ is not via Kong:
|
||||
# SUPABASE_PROJECT1_META_URL=http://localhost:5555
|
||||
#
|
||||
# Optional: Basic Auth for postgres-meta when exposed via a public URL.
|
||||
# Format: username:password — not needed for internal Docker networks or Kong.
|
||||
# SUPABASE_PROJECT1_META_AUTH=admin:secretpassword
|
||||
|
||||
# --- OpenPanel ---
|
||||
# OPENPANEL_INSTANCE1_URL=https://openpanel.example.com
|
||||
# OPENPANEL_INSTANCE1_CLIENT_ID=your_client_id
|
||||
# OPENPANEL_INSTANCE1_CLIENT_SECRET=your_client_secret
|
||||
# OPENPANEL_INSTANCE1_ALIAS=myopenpanel
|
||||
|
||||
# --- Appwrite ---
|
||||
# APPWRITE_PROJECT1_URL=https://appwrite.example.com
|
||||
# APPWRITE_PROJECT1_API_KEY=your_appwrite_api_key
|
||||
# APPWRITE_PROJECT1_PROJECT_ID=your_project_id
|
||||
# APPWRITE_PROJECT1_ALIAS=myappwrite
|
||||
|
||||
# --- Directus ---
|
||||
# DIRECTUS_INSTANCE1_URL=https://directus.example.com
|
||||
# DIRECTUS_INSTANCE1_TOKEN=your_directus_token
|
||||
# DIRECTUS_INSTANCE1_ALIAS=mydirectus
|
||||
# Supported plugins: WordPress, WooCommerce, Supabase, OpenPanel,
|
||||
# Gitea, n8n, Appwrite, Directus, WordPress Advanced
|
||||
|
||||
# ============================================
|
||||
# OAUTH (optional — for ChatGPT/Claude auto-registration)
|
||||
|
||||
@@ -4,8 +4,8 @@ Uses REST APIs:
|
||||
- Export API (GET /export/events, /export/charts) for raw data export
|
||||
- Insights API (GET /insights/:projectId/*) for analytics queries
|
||||
|
||||
Note: project_id is optional if configured in environment variables.
|
||||
When not provided, the default project_id from OPENPANEL_SITE1_PROJECT_ID is used.
|
||||
Note: project_id is optional if configured in the site settings.
|
||||
When not provided, the default project_id from the site configuration is used.
|
||||
|
||||
Requires 'read' or 'root' mode client for Export API.
|
||||
"""
|
||||
|
||||
@@ -23,6 +23,6 @@ def get_project_id(client: OpenPanelClient, project_id: str | None) -> str:
|
||||
return client.default_project_id
|
||||
raise ValueError(
|
||||
"project_id is required. Either provide it as a parameter or configure "
|
||||
"OPENPANEL_SITE1_PROJECT_ID in environment variables. "
|
||||
"the project_id field when adding the site in the dashboard. "
|
||||
"You can find your Project ID in OpenPanel Dashboard → Project Settings."
|
||||
)
|
||||
|
||||
@@ -483,6 +483,8 @@ async def delete_user_factor(client: SupabaseClient, user_id: str, factor_id: st
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
|
||||
|
||||
async def search_users(
|
||||
client: SupabaseClient, query: str, page: int = 1, per_page: int = 50
|
||||
) -> str:
|
||||
|
||||
@@ -67,18 +67,17 @@ class WordPressClient:
|
||||
# Validate required parameters
|
||||
if not site_url:
|
||||
raise ConfigurationError(
|
||||
"Site URL is not configured. "
|
||||
"Please set the URL environment variable (e.g., WORDPRESS_SITE1_URL)."
|
||||
"Site URL is not configured. " "Please add or update the site in the dashboard."
|
||||
)
|
||||
if not username:
|
||||
raise ConfigurationError(
|
||||
"Username is not configured. "
|
||||
"Please set the USERNAME environment variable (e.g., WORDPRESS_SITE1_USERNAME)."
|
||||
"Please update the site credentials in the dashboard."
|
||||
)
|
||||
if not app_password:
|
||||
raise ConfigurationError(
|
||||
"App password is not configured. "
|
||||
"Please set the APP_PASSWORD environment variable (e.g., WORDPRESS_SITE1_APP_PASSWORD)."
|
||||
"Please update the site credentials in the dashboard."
|
||||
)
|
||||
|
||||
self.site_url = site_url.rstrip("/")
|
||||
|
||||
@@ -77,12 +77,8 @@ class WPCLIManager:
|
||||
try:
|
||||
# First, test if we have Docker socket access
|
||||
test_process = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"version",
|
||||
"--format",
|
||||
"{{.Server.Version}}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
"docker", "version", "--format", "{{.Server.Version}}",
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
test_stdout, test_stderr = await asyncio.wait_for(
|
||||
@@ -103,15 +99,10 @@ class WPCLIManager:
|
||||
|
||||
# Now check for our specific container using exact name match
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"ps",
|
||||
"--all",
|
||||
"--filter",
|
||||
f"name=^{self.container_name}$",
|
||||
"--format",
|
||||
"{{.Names}}|{{.Status}}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
"docker", "ps", "--all",
|
||||
"--filter", f"name=^{self.container_name}$",
|
||||
"--format", "{{.Names}}|{{.Status}}",
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=5.0)
|
||||
@@ -126,13 +117,8 @@ class WPCLIManager:
|
||||
if not output:
|
||||
# Container not found - get list of available containers for helpful error
|
||||
list_process = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"ps",
|
||||
"--all",
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
"docker", "ps", "--all", "--format", "{{.Names}}",
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
list_stdout, _ = await list_process.communicate()
|
||||
available = list_stdout.decode().strip().split("\n") if list_stdout else []
|
||||
@@ -184,14 +170,9 @@ class WPCLIManager:
|
||||
try:
|
||||
# Try to run wp --version
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"exec",
|
||||
self.container_name,
|
||||
"wp",
|
||||
"--version",
|
||||
"--allow-root",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
"docker", "exec", self.container_name,
|
||||
"wp", "--version", "--allow-root",
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=5.0)
|
||||
@@ -310,9 +291,7 @@ class WPCLIManager:
|
||||
)
|
||||
|
||||
# 4. Build docker exec command (split command into args to prevent shell injection)
|
||||
cmd_parts = (
|
||||
["docker", "exec", self.container_name, "wp"] + command.split() + ["--allow-root"]
|
||||
)
|
||||
cmd_parts = ["docker", "exec", self.container_name, "wp"] + command.split() + ["--allow-root"]
|
||||
|
||||
self.logger.info(f"Executing: {' '.join(cmd_parts)}")
|
||||
|
||||
@@ -603,29 +582,17 @@ class WPCLIManager:
|
||||
try:
|
||||
# Try GNU stat first, fall back to BSD stat
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"exec",
|
||||
self.container_name,
|
||||
"stat",
|
||||
"-c",
|
||||
"%s",
|
||||
export_path,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
"docker", "exec", self.container_name,
|
||||
"stat", "-c", "%s", export_path,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(process.communicate(), timeout=5.0)
|
||||
if process.returncode != 0:
|
||||
# BSD stat fallback
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"exec",
|
||||
self.container_name,
|
||||
"stat",
|
||||
"-f",
|
||||
"%z",
|
||||
export_path,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
"docker", "exec", self.container_name,
|
||||
"stat", "-f", "%z", export_path,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(process.communicate(), timeout=5.0)
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ class WordPressAdvancedPlugin(BasePlugin):
|
||||
if not container_name:
|
||||
raise ValueError(
|
||||
"WordPress Advanced plugin requires 'container' configuration. "
|
||||
"Please set WORDPRESS_ADVANCED_SITE1_CONTAINER in environment variables."
|
||||
"Please set the 'container' field when adding the site in the dashboard."
|
||||
)
|
||||
|
||||
# Import WP-CLI manager
|
||||
|
||||
34
server.py
34
server.py
@@ -14,8 +14,9 @@ Usage:
|
||||
|
||||
Environment Variables:
|
||||
MASTER_API_KEY: Master API key for authentication
|
||||
{PLUGIN_TYPE}_{PROJECT_ID}_{CONFIG_KEY}: Project configurations
|
||||
LOG_LEVEL: Logging level (DEBUG, INFO, WARNING, ERROR)
|
||||
|
||||
Sites are managed via the web dashboard and stored in SQLite (DB-based).
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -66,8 +67,6 @@ from core.dashboard.routes import (
|
||||
api_get_config,
|
||||
api_list_keys,
|
||||
api_list_sites,
|
||||
# K.5: Settings routes
|
||||
api_save_setting,
|
||||
api_test_site,
|
||||
api_update_site,
|
||||
# E.2: OAuth Social Login routes
|
||||
@@ -109,6 +108,8 @@ from core.dashboard.routes import (
|
||||
# F.3: Service pages
|
||||
dashboard_service_page,
|
||||
dashboard_services_list,
|
||||
# K.5: Settings routes
|
||||
api_save_setting,
|
||||
dashboard_settings_page,
|
||||
# E.3: Site Management pages
|
||||
dashboard_sites_add,
|
||||
@@ -308,7 +309,6 @@ from plugins import registry as plugin_registry
|
||||
|
||||
site_manager = get_site_manager()
|
||||
plugin_types = plugin_registry.get_registered_types()
|
||||
site_manager.discover_sites(plugin_types)
|
||||
# Initialize tool registry (central tool management)
|
||||
tool_registry = get_tool_registry()
|
||||
|
||||
@@ -323,24 +323,8 @@ if auth_manager._is_temporary_key:
|
||||
logger.info(f"Master API Key (temporary): {_mk}")
|
||||
else:
|
||||
logger.info(f"Master API Key: {_mk[:8]}***{_mk[-4:]}")
|
||||
logger.info(f"Discovered {len(project_manager.projects)} per-site project instances (legacy)")
|
||||
logger.info(
|
||||
f"Discovered {site_manager.get_count()} unique sites across {len(plugin_types)} plugin types"
|
||||
)
|
||||
logger.info(f"Site breakdown: {site_manager.get_count_by_type()}")
|
||||
|
||||
# Log discovered projects
|
||||
for full_id, plugin in project_manager.projects.items():
|
||||
logger.info(f" - {full_id} ({plugin.get_plugin_name()})")
|
||||
|
||||
# Log discovered sites
|
||||
logger.info("\nDiscovered sites:")
|
||||
for site_info in site_manager.list_all_sites():
|
||||
alias_display = (
|
||||
f" (alias: {site_info['alias']})" if site_info["alias"] != site_info["site_id"] else ""
|
||||
)
|
||||
logger.info(f" - {site_info['full_id']}{alias_display}")
|
||||
|
||||
logger.info(f"Plugin types registered: {', '.join(plugin_types)}")
|
||||
logger.info("Sites are managed via the web dashboard (DB-based)")
|
||||
logger.info("=" * 60)
|
||||
|
||||
|
||||
@@ -5032,12 +5016,6 @@ def main():
|
||||
logger.info(f"Host: {args.host}")
|
||||
logger.info(f"Port: {args.port}")
|
||||
|
||||
# Check if any sites were discovered (SiteManager is the primary discovery system)
|
||||
if site_manager.get_count() == 0:
|
||||
logger.warning("No sites discovered! Check your environment variables.")
|
||||
logger.warning("Expected format: {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}=value")
|
||||
logger.warning("Example: WORDPRESS_SITE1_URL=https://example.com")
|
||||
|
||||
try:
|
||||
if args.transport == "stdio":
|
||||
# stdio doesn't support multi-endpoint
|
||||
|
||||
@@ -165,7 +165,6 @@ rate_limiter = get_rate_limiter()
|
||||
# Initialize site manager
|
||||
site_manager = get_site_manager()
|
||||
plugin_types = plugin_registry.get_registered_types()
|
||||
site_manager.discover_sites(plugin_types)
|
||||
|
||||
# WooCommerce can fallback to WordPress site configurations if no WOOCOMMERCE_* env vars
|
||||
# This mapping is only used when woocommerce has no sites configured
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for Admin System Unification (F.4)."""
|
||||
|
||||
|
||||
from core.dashboard.auth import (
|
||||
DashboardSession,
|
||||
get_session_display_info,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for Site Manager (core/site_manager.py)."""
|
||||
"""Tests for Site Manager (core/site_manager.py) — registration and lookup."""
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -127,69 +127,6 @@ class TestSiteManagerRegistration:
|
||||
assert manager.list_sites("nonexistent") == []
|
||||
|
||||
|
||||
class TestSiteManagerDiscovery:
|
||||
"""Test site discovery from environment variables."""
|
||||
|
||||
def test_discover_wordpress_sites(self, monkeypatch):
|
||||
"""Should discover sites from WORDPRESS_SITE1_* env vars."""
|
||||
monkeypatch.setenv("WORDPRESS_SITE1_URL", "https://wp1.example.com")
|
||||
monkeypatch.setenv("WORDPRESS_SITE1_USERNAME", "admin")
|
||||
monkeypatch.setenv("WORDPRESS_SITE1_APP_PASSWORD", "xxxx")
|
||||
monkeypatch.setenv("WORDPRESS_SITE1_ALIAS", "myblog")
|
||||
|
||||
manager = SiteManager()
|
||||
count = manager.discover_sites(["wordpress"])
|
||||
|
||||
assert count == 1
|
||||
config = manager.get_site_config("wordpress", "site1")
|
||||
assert config.url == "https://wp1.example.com"
|
||||
assert config.username == "admin"
|
||||
assert config.alias == "myblog"
|
||||
|
||||
def test_discover_multiple_sites(self, monkeypatch):
|
||||
"""Should discover multiple sites for same plugin type."""
|
||||
monkeypatch.setenv("WORDPRESS_SITE1_URL", "https://wp1.example.com")
|
||||
monkeypatch.setenv("WORDPRESS_SITE1_USERNAME", "admin")
|
||||
monkeypatch.setenv("WORDPRESS_SITE2_URL", "https://wp2.example.com")
|
||||
monkeypatch.setenv("WORDPRESS_SITE2_USERNAME", "editor")
|
||||
|
||||
manager = SiteManager()
|
||||
count = manager.discover_sites(["wordpress"])
|
||||
|
||||
assert count == 2
|
||||
|
||||
def test_discover_across_plugin_types(self, monkeypatch):
|
||||
"""Should discover sites across different plugin types."""
|
||||
monkeypatch.setenv("WORDPRESS_SITE1_URL", "https://wp.example.com")
|
||||
monkeypatch.setenv("WORDPRESS_SITE1_USERNAME", "admin")
|
||||
monkeypatch.setenv("GITEA_SITE1_URL", "https://git.example.com")
|
||||
monkeypatch.setenv("GITEA_SITE1_TOKEN", "tok_123")
|
||||
|
||||
manager = SiteManager()
|
||||
count = manager.discover_sites(["wordpress", "gitea"])
|
||||
|
||||
assert count == 2
|
||||
|
||||
def test_reserved_words_skipped(self, monkeypatch):
|
||||
"""Reserved words like LIMIT, RATE should not become site IDs."""
|
||||
monkeypatch.setenv("WORDPRESS_LIMIT_PER_MINUTE", "100")
|
||||
monkeypatch.setenv("WORDPRESS_RATE_PER_HOUR", "500")
|
||||
|
||||
manager = SiteManager()
|
||||
count = manager.discover_sites(["wordpress"])
|
||||
|
||||
assert count == 0
|
||||
|
||||
def test_incomplete_config_skipped(self, monkeypatch):
|
||||
"""Sites with no config data (only prefix match) should be skipped."""
|
||||
# WORDPRESS_SITE1_ exists as prefix but no actual config keys
|
||||
# This is hard to test directly since env vars always have a value
|
||||
# Instead, test that a site with only site_id and plugin_type (no config) is skipped
|
||||
manager = SiteManager()
|
||||
count = manager.discover_sites(["wordpress"])
|
||||
assert count == 0
|
||||
|
||||
|
||||
class TestSiteManagerCounts:
|
||||
"""Test counting and listing methods."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user