Files
ServerKit/backend/app/services/plugin_service.py
T
Juan DenisandClaude Fable 5 0ec55accbe feat(extensions): settings.section slot + manifest shape lint (#52)
Extensions can now contribute Settings sections: a settings.section
PluginSlot renders below the active Settings tab with the tab id as
context. _validate_manifest grew shape checks for everything the
platform consumes declaratively — module:attr refs (entry_point,
socket_entry, models, lifecycle hooks, job handlers), jobs/schedules
shapes, and per-kind required contribution keys (nav/routes/tabs/
palette/widgets/layouts) — so a malformed block fails the install with
a message naming each problem instead of being silently dropped at
runtime. Unknown contribution kinds only warn (forward compat). The
scaffolder gained --validate <path> mirroring the same rules for
authors, wired into the conversion recipe in docs/EXTENSIONS.md.

3 new lint tests incl. one asserting every shipped builtin manifest
passes; full backend suite 1227 green; lint 0 errors; build clean;
--validate smoke-tested on a good and a bad manifest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 02:36:46 -04:00

1596 lines
63 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Plugin Service - Download, install, and manage ServerKit plugins from URLs.
Plugins are zip files containing a plugin.json manifest, optional backend/
and frontend/ directories. They get extracted into the ServerKit plugins
directories and auto-registered at startup.
"""
import io
import json
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
import requests
from app import db
from app.models.plugin import InstalledPlugin
logger = logging.getLogger(__name__)
# Resolve paths relative to the backend directory.
# __file__ = backend/app/services/plugin_service.py
# _APP_DIR = backend/app/
# _BACKEND_ROOT = backend/
# _PROJECT_ROOT = ServerKit/
#
# Both targets are env-var overridable. The defaults work for native dev
# (backend run from a checkout). They break in Docker because the backend
# image only contains /app — the host's frontend directory isn't mounted —
# so dockerized panels MUST set SERVERKIT_FRONTEND_PLUGINS_DIR (and bind
# mount the corresponding host folder) when installing plugins that ship
# a frontend.
_APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_BACKEND_ROOT = os.path.dirname(_APP_DIR)
_PROJECT_ROOT = os.path.dirname(_BACKEND_ROOT)
BACKEND_PLUGINS_DIR = os.environ.get(
'SERVERKIT_BACKEND_PLUGINS_DIR',
os.path.join(_APP_DIR, 'plugins'),
)
FRONTEND_PLUGINS_DIR = os.environ.get(
'SERVERKIT_FRONTEND_PLUGINS_DIR',
os.path.join(_PROJECT_ROOT, 'frontend', 'src', 'plugins'),
)
# Folder of bundled-with-the-repo extensions that ship as plugins
# but are not installed by default. The discovery endpoint enumerates
# this folder so the Marketplace can show one-click installs without
# requiring users to know absolute paths.
BUILTIN_EXTENSIONS_DIR = os.environ.get(
'SERVERKIT_BUILTIN_EXTENSIONS_DIR',
os.path.join(_PROJECT_ROOT, 'builtin-extensions'),
)
# Flagship extensions (decision D4): bundled, installed-by-default on EVERY panel
# (fresh and upgrade), and uninstallable. Unlike the niche Tier-1 conversions
# (handled one-shot in extension_migration), a flagship is seeded in-place — no
# file copy — its backend loads straight from builtin-extensions/ and its frontend
# is pre-bundled (D5). A user uninstall records a marker so it stays gone.
FLAGSHIP_SLUGS = ['serverkit-wordpress', 'serverkit-cloudflare-ops']
_FLAGSHIP_UNINSTALLED_KEY = 'extensions.flagship_uninstalled'
def _ensure_builtin_backend_importable(slug):
"""Register ``builtin-extensions/<slug>/backend`` as the dashed package
``app.plugins.<slug>`` in ``sys.modules`` (in-place, no copy), so its
blueprints and services import — and relative imports inside resolve —
without the plugin being copy-installed under ``app/plugins/``.
This is what makes a dashed, default-installed flagship loadable uniformly in
production, dev checkouts, and the test suite. Idempotent; returns ``True`` if
the package is (now) importable.
"""
import importlib
import importlib.util
pkg = f'app.plugins.{slug}'
if pkg in sys.modules:
return True
try:
importlib.import_module(pkg) # already copy-installed under app/plugins/
return True
except ImportError:
pass
backend_dir = os.path.join(BUILTIN_EXTENSIONS_DIR, slug, 'backend')
init_py = os.path.join(backend_dir, '__init__.py')
if not os.path.isfile(init_py):
return False
try:
importlib.import_module('app.plugins') # ensure the parent package exists
spec = importlib.util.spec_from_file_location(
pkg, init_py, submodule_search_locations=[backend_dir]
)
module = importlib.util.module_from_spec(spec)
sys.modules[pkg] = module
spec.loader.exec_module(module)
return True
except Exception as e:
logger.warning(f'In-place load of {pkg} failed: {e}')
sys.modules.pop(pkg, None)
return False
def _ensure_backend_dir():
"""Create the backend plugin dir; raise a useful error if we can't."""
try:
os.makedirs(BACKEND_PLUGINS_DIR, exist_ok=True)
except OSError as e:
raise ValueError(
f"Cannot create backend plugin directory at {BACKEND_PLUGINS_DIR}: {e}. "
f"Set SERVERKIT_BACKEND_PLUGINS_DIR to a writable path."
)
init_path = os.path.join(BACKEND_PLUGINS_DIR, '__init__.py')
if not os.path.exists(init_path):
with open(init_path, 'w') as f:
f.write('')
def _ensure_frontend_dir():
"""Create the frontend plugin dir on demand. Called only when a plugin
actually ships frontend content — plugins without frontends never need
this directory and shouldn't fail just because it doesn't exist (a
common case in Docker)."""
try:
os.makedirs(FRONTEND_PLUGINS_DIR, exist_ok=True)
except OSError as e:
raise ValueError(
f"This plugin ships a frontend, but the panel can't write to "
f"the frontend plugin directory ({FRONTEND_PLUGINS_DIR}): {e}.\n\n"
f"If the panel runs in Docker, this is expected — the container "
f"sees only /app, not the host's frontend folder. Two fixes:\n"
f" • Bind-mount the host's frontend/src/plugins into the "
f"container and set SERVERKIT_FRONTEND_PLUGINS_DIR to that path.\n"
f" • Or run the backend natively for plugin development.\n\n"
f"You can also install the backend half only by stripping the "
f"frontend/ folder from the plugin zip, but the UI half won't load."
)
def _ensure_dirs():
"""Backwards-compatible eager init for backend dir only. Frontend dir is
lazy now — see _ensure_frontend_dir."""
_ensure_backend_dir()
def _resolve_github_url(url):
"""Convert a GitHub repo URL to the latest release zip download URL.
Handles:
- https://github.com/user/repo -> latest release zip
- https://github.com/user/repo/releases/tag/v1.0.0 -> that release zip
- Direct zip URLs pass through unchanged
"""
if url.endswith('.zip'):
return url
# Match github.com/owner/repo patterns
gh_match = re.match(
r'https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?(?:/releases/tag/([^/]+))?/?$',
url,
)
if not gh_match:
return url
owner, repo, tag = gh_match.groups()
if tag:
# Specific release - get its assets
api_url = f'https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}'
else:
# Latest release
api_url = f'https://api.github.com/repos/{owner}/{repo}/releases/latest'
try:
resp = requests.get(api_url, timeout=15, headers={'Accept': 'application/vnd.github+json'})
resp.raise_for_status()
release = resp.json()
# Look for a .zip asset (prefer plugin zip over source)
for asset in release.get('assets', []):
if asset['name'].endswith('.zip'):
return asset['browser_download_url']
# Fallback to source zipball
return release.get('zipball_url', url)
except Exception as e:
logger.warning(f'Could not resolve GitHub release URL: {e}')
# Fallback: try the zipball endpoint directly
if tag:
return f'https://api.github.com/repos/{owner}/{repo}/zipball/{tag}'
return f'https://api.github.com/repos/{owner}/{repo}/zipball'
def _download_zip(url):
"""Download a zip file from URL and return bytes."""
resolved = _resolve_github_url(url)
logger.info(f'Downloading plugin from: {resolved}')
resp = requests.get(resolved, timeout=120, stream=True, headers={
'Accept': 'application/octet-stream',
'User-Agent': 'ServerKit-Plugin-Installer/1.0',
})
resp.raise_for_status()
buf = io.BytesIO()
for chunk in resp.iter_content(chunk_size=8192):
buf.write(chunk)
buf.seek(0)
return buf
def _find_manifest(zf):
"""Find plugin.json inside the zip, handling nested directories (GitHub zipball nesting)."""
for name in zf.namelist():
basename = os.path.basename(name)
if basename == 'plugin.json':
# Return the directory prefix so we can strip it
prefix = name[: -len('plugin.json')]
return name, prefix
return None, None
# 'module:attr' references (entry_point, socket_entry, models, lifecycle hooks,
# job handlers). Module part may be dotted; attr must be a bare identifier.
_MODULE_REF_RE = re.compile(r'^[A-Za-z_][\w.]*:[A-Za-z_]\w*$')
# Required keys per contribution kind (mirrors GET /plugins/manifest-spec and
# docs/EXTENSIONS.md — keep the three in sync).
_REQUIRED_CONTRIB_KEYS = {
'nav': ('id', 'label', 'route'),
'routes': ('path', 'component'),
'tabs': ('group', 'to', 'label'),
'command_palette': ('label', 'path'),
'widgets': ('slot', 'component'),
'layouts': ('id', 'component'),
}
_KNOWN_CONTRIB_KINDS = set(_REQUIRED_CONTRIB_KEYS) | {'page_titles', 'ai'}
def _validate_manifest(manifest):
"""Validate the manifest: required fields plus the SHAPE of every
declarative block the platform consumes (#52).
A malformed block used to be silently skipped at runtime (contribution
dropped, socket never registered, job never scheduled) — hard to debug.
Now the install fails with a message naming each problem. Unknown
contribution kinds only warn, so newer manifests stay installable on
older panels (forward compat).
"""
required = ['name', 'display_name', 'version']
missing = [f for f in required if f not in manifest]
if missing:
raise ValueError(f"Manifest missing required fields: {', '.join(missing)}")
# Sanitize the name for use as a directory
name = manifest['name']
if not re.match(r'^[a-zA-Z0-9_-]+$', name):
raise ValueError(f"Plugin name must be alphanumeric/dashes/underscores: {name}")
problems = []
for field in ('entry_point', 'socket_entry', 'models'):
val = manifest.get(field)
if val and not (isinstance(val, str) and _MODULE_REF_RE.match(val)):
problems.append(f"{field} must be a 'module:attr' string (got {val!r})")
lifecycle = manifest.get('lifecycle')
if lifecycle is not None:
if not isinstance(lifecycle, dict):
problems.append('lifecycle must be an object of phase -> module:func')
else:
for phase, target in lifecycle.items():
if not (isinstance(target, str) and _MODULE_REF_RE.match(target)):
problems.append(
f"lifecycle.{phase} must be a 'module:func' string (got {target!r})")
jobs = manifest.get('jobs')
if jobs is not None:
if not isinstance(jobs, list):
problems.append('jobs must be a list of {kind, handler}')
else:
for i, j in enumerate(jobs):
if not (isinstance(j, dict) and j.get('kind')
and isinstance(j.get('handler'), str)
and _MODULE_REF_RE.match(j['handler'])):
problems.append(f"jobs[{i}] must be {{kind, handler: 'module:func'}}")
schedules = manifest.get('schedules')
if schedules is not None:
if not isinstance(schedules, list):
problems.append('schedules must be a list of {name, kind, cron?|interval_seconds?}')
else:
for i, s in enumerate(schedules):
if not (isinstance(s, dict) and s.get('name') and s.get('kind')):
problems.append(f'schedules[{i}] needs name and kind')
contrib = manifest.get('contributions')
if contrib is not None and not isinstance(contrib, dict):
problems.append('contributions must be an object')
elif isinstance(contrib, dict):
for key in contrib:
if key not in _KNOWN_CONTRIB_KINDS:
logger.warning(
f"Manifest for {name}: unknown contribution kind '{key}' (ignored)")
for kind, req in _REQUIRED_CONTRIB_KEYS.items():
entries = contrib.get(kind)
if entries is None:
continue
if not isinstance(entries, list):
problems.append(f'contributions.{kind} must be a list')
continue
for i, entry in enumerate(entries):
if not isinstance(entry, dict):
problems.append(f'contributions.{kind}[{i}] must be an object')
continue
missing_keys = [k for k in req if not entry.get(k)]
if missing_keys:
problems.append(
f"contributions.{kind}[{i}] missing {', '.join(missing_keys)}")
titles = contrib.get('page_titles')
if titles is not None and not isinstance(titles, dict):
problems.append('contributions.page_titles must be an object of path -> title')
if problems:
raise ValueError(
'Manifest validation failed: ' + '; '.join(problems)
+ '. See GET /api/v1/plugins/manifest-spec or docs/EXTENSIONS.md.')
return True
def _assert_manifest_panel_compatible(manifest):
"""Enforce a manifest's min/max_panel_version at install (#28), for every
install source (URL/upload/local/builtin), not just the registry. A manifest
without bounds installs anywhere."""
from app.utils.version import version_satisfies, get_panel_version
minv = manifest.get('min_panel_version')
maxv = manifest.get('max_panel_version')
if not (minv or maxv):
return
panel = get_panel_version()
if not version_satisfies(panel, minv, maxv):
raise ValueError(
f"{manifest.get('display_name') or manifest.get('name')} "
f"v{manifest.get('version')} needs panel {minv or '*'}–{maxv or '*'} "
f"(this panel is {panel})."
)
def _safe_extract_path(dest_root, rel_path):
"""Resolve `rel_path` under `dest_root` and assert it stays inside.
Defense against Zip Slip: a malicious archive can carry entries like
`../../etc/cron.d/foo` or absolute paths. Without this check, the
extract loop happily writes outside the plugin directory and into
anything the backend process can touch.
Returns the validated absolute output path, or raises ValueError.
"""
if not rel_path:
raise ValueError('empty path in archive')
normalized = rel_path.replace('\\', '/').lstrip('/')
if normalized.startswith('/') or ':' in normalized.split('/')[0]:
raise ValueError(f'absolute path in archive: {rel_path!r}')
if '..' in normalized.split('/'):
raise ValueError(f'path traversal in archive: {rel_path!r}')
out = os.path.normpath(os.path.join(dest_root, normalized))
root = os.path.normpath(dest_root) + os.sep
if not (out + os.sep).startswith(root):
raise ValueError(f'archive entry escapes destination: {rel_path!r}')
return out
def _update_plugin_metadata(plugin, manifest):
"""Sync mutable manifest-derived fields onto an existing plugin row.
Used on reinstall: the new manifest may have a fresh display_name,
author, description, etc. Without this, those fields go stale and
the UI keeps showing the original values.
"""
plugin.name = manifest['name']
plugin.display_name = manifest['display_name']
plugin.description = manifest.get('description', '')
plugin.author = manifest.get('author', '')
plugin.homepage = manifest.get('homepage', '')
plugin.repository = manifest.get('repository', '')
plugin.license = manifest.get('license', '')
plugin.category = manifest.get('category', 'utility')
def install_from_url(url, user_id=None, expected_sha256=None, force=False,
hot_load=True):
"""Download and install a plugin from a URL.
Args:
url: GitHub repo URL, release URL, or direct zip URL
user_id: ID of the user performing the install
expected_sha256: if given, the downloaded zip must match this digest
before extraction — a mismatch is a hard failure (no partial install)
force: allow reinstalling over an already-active plugin (used by updates)
hot_load: register the blueprint into the running app immediately.
The boot-time repair pass (#48) passes False — load_all_plugins
registers right after, and a double registration would error.
Returns:
InstalledPlugin instance
"""
try:
buf = _download_zip(url)
except Exception as e:
raise ValueError(f'Failed to download plugin: {e}')
if expected_sha256:
import hashlib
digest = hashlib.sha256(buf.getvalue()).hexdigest()
if digest.lower() != str(expected_sha256).lower():
raise ValueError(
f'Checksum mismatch — refusing to install. '
f'Expected sha256 {expected_sha256}, got {digest}.'
)
buf.seek(0)
return _install_from_buffer(
buf, source_url=url, source_type='url', user_id=user_id, force=force,
hot_load=hot_load,
)
def install_from_path(path, user_id=None, force=False, hot_load=True):
"""Install a plugin from a local directory on the panel host.
Useful during plugin development: point at the working tree, install,
iterate. Internally we zip the folder in memory and reuse the same
install pipeline as URL/upload installs so behavior is identical.
Note: "local" here means local to the *panel backend*, not to the
user's browser. If the backend runs in Docker, browser-host paths
won't resolve — the user should either bind-mount their plugin
source into the container or use install_from_zip via the upload
endpoint instead.
"""
if not path:
raise ValueError('path is required')
raw = path
# Windows path on a non-Windows panel host is the most common
# foot-gun (typical case: backend in Docker, dev on Windows). Detect
# and reject with a helpful message before os.path.abspath turns it
# into garbage like /app/C:\Users\...
if os.name != 'nt' and len(path) >= 3 and path[1:3] == ':\\':
raise ValueError(
f"'{raw}' looks like a Windows path, but the panel backend is "
f"running on {os.name!r} (likely a Linux container). The folder "
f"install runs on the panel host's filesystem, not your browser's. "
f"Either bind-mount your plugin source into the backend container, "
f"or use 'Upload Zip' instead."
)
path = os.path.abspath(os.path.expanduser(path))
if not os.path.isdir(path):
raise ValueError(
f"Not a directory: {path}\n"
f"This path is resolved on the panel backend ({os.name!r}), not "
f"your browser. If the backend runs in Docker, the path must "
f"exist inside the container — bind-mount the source folder or "
f"use 'Upload Zip'."
)
if not os.path.exists(os.path.join(path, 'plugin.json')):
raise ValueError(f'No plugin.json in {path}')
# Zip the folder in memory, skipping dev junk that bloats the bundle.
skip_dirs = {
'.git', '.github', 'node_modules', '__pycache__',
'.venv', 'venv', 'dist', 'build', '.pytest_cache',
'.idea', '.vscode',
}
skip_files_endswith = ('.pyc', '.pyo')
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(path):
# mutate dirs in place so os.walk skips them
dirs[:] = [d for d in dirs if d not in skip_dirs]
for name in files:
if name.endswith(skip_files_endswith):
continue
full = os.path.join(root, name)
rel = os.path.relpath(full, path).replace(os.sep, '/')
zf.write(full, rel)
buf.seek(0)
return _install_from_buffer(
buf, source_url=path, source_type='local', user_id=user_id,
force=force, hot_load=hot_load,
)
def install_from_zip(zip_bytes, user_id=None, source_name=None):
"""Install a plugin from raw zip bytes (e.g. a multipart upload)."""
if not zip_bytes:
raise ValueError('Empty upload')
buf = io.BytesIO(zip_bytes)
return _install_from_buffer(
buf,
source_url=source_name or 'uploaded.zip',
source_type='upload',
user_id=user_id,
)
def _install_from_buffer(buf, source_url, source_type, user_id=None, force=False,
hot_load=True):
"""Shared install pipeline: takes a seekable BytesIO containing a zip,
extracts it into the panel's plugin dirs, and registers / hot-loads
the resulting blueprint. All public install_* helpers funnel here so
behavior matches across URL / local / upload sources.
`force=True` allows reinstalling over an active plugin (the update path);
normal installs still refuse to clobber an active install. `hot_load=False`
skips the immediate blueprint registration (the boot repair pass, #48).
"""
_ensure_dirs()
# Open zip
try:
zf = zipfile.ZipFile(buf)
except zipfile.BadZipFile:
raise ValueError('File is not a valid zip archive')
# Find and read manifest
manifest_path, prefix = _find_manifest(zf)
if not manifest_path:
raise ValueError('No plugin.json found in archive')
manifest = json.loads(zf.read(manifest_path))
_validate_manifest(manifest)
_assert_manifest_panel_compatible(manifest)
slug = manifest['name']
# Check if already installed
existing = InstalledPlugin.query.filter_by(slug=slug).first()
if existing and existing.status in ('active', 'installing') and not force:
raise ValueError(f"Plugin '{slug}' is already installed (v{existing.version}). Uninstall first to reinstall.")
# Remember the version we're replacing so we can run lifecycle.upgrade when
# it actually changes (reinstall/update over an existing row).
previous_version = existing.version if existing else None
# Create DB record early so we can track errors
if existing:
plugin = existing
plugin.status = InstalledPlugin.STATUS_INSTALLING
plugin.error_message = None
plugin.version = manifest['version']
plugin.source_url = source_url
plugin.source_type = source_type
plugin.manifest = manifest
# Refresh display/description/author/etc from the new manifest so
# the UI reflects the just-installed version, not the original.
_update_plugin_metadata(plugin, manifest)
else:
plugin = InstalledPlugin(
name=manifest['name'],
display_name=manifest['display_name'],
slug=slug,
version=manifest['version'],
description=manifest.get('description', ''),
author=manifest.get('author', ''),
homepage=manifest.get('homepage', ''),
repository=manifest.get('repository', ''),
license=manifest.get('license', ''),
category=manifest.get('category', 'utility'),
source_url=source_url,
source_type=source_type,
installed_by=user_id,
status=InstalledPlugin.STATUS_INSTALLING,
)
plugin.manifest = manifest
db.session.add(plugin)
db.session.commit()
try:
# First pass — figure out what the archive contains. This lets us
# bail early with a useful error if the plugin needs a frontend
# dir we can't write to (the dockerized panel case).
has_backend = False
has_frontend = False
for member in zf.namelist():
rel = member[len(prefix):] if prefix else member
if not rel or rel.endswith('/'):
continue
if rel.startswith('backend/'):
has_backend = True
elif rel.startswith('frontend/'):
has_frontend = True
if has_frontend:
_ensure_frontend_dir() # raises with a helpful message on failure
backend_dest = os.path.join(BACKEND_PLUGINS_DIR, slug)
frontend_dest = os.path.join(FRONTEND_PLUGINS_DIR, slug)
# Clean old install
if os.path.exists(backend_dest):
shutil.rmtree(backend_dest)
if has_frontend and os.path.exists(frontend_dest):
shutil.rmtree(frontend_dest)
for member in zf.namelist():
# Strip the GitHub zipball prefix
rel_path = member[len(prefix):] if prefix else member
if not rel_path or rel_path.endswith('/'):
continue
if rel_path.startswith('backend/'):
# Zip Slip defense: _safe_extract_path rejects absolute
# paths, .. segments, and anything that resolves outside
# backend_dest after normalization.
out_path = _safe_extract_path(
backend_dest, rel_path[len('backend/'):]
)
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with zf.open(member) as src, open(out_path, 'wb') as dst:
dst.write(src.read())
elif rel_path.startswith('frontend/'):
out_path = _safe_extract_path(
frontend_dest, rel_path[len('frontend/'):]
)
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with zf.open(member) as src, open(out_path, 'wb') as dst:
dst.write(src.read())
elif rel_path == 'requirements.txt':
# Plugin Python deps are sandboxed by default: installing
# them runs pip with the backend's privileges, which means
# arbitrary code via setup.py hooks. Operators must opt in
# by setting SERVERKIT_ALLOW_PLUGIN_PIP=1.
req_content = zf.read(member).decode('utf-8')
if req_content.strip() and os.environ.get(
'SERVERKIT_ALLOW_PLUGIN_PIP', ''
).lower() in ('1', 'true', 'yes'):
_install_requirements(req_content, slug)
elif req_content.strip():
# Persist the requirements file so the admin can review
# and install it manually if they want.
req_out = _safe_extract_path(backend_dest, 'requirements.txt')
os.makedirs(os.path.dirname(req_out), exist_ok=True)
with open(req_out, 'w', encoding='utf-8') as f:
f.write(req_content)
logger.warning(
f"Plugin {slug} ships requirements.txt; skipping install "
f"(set SERVERKIT_ALLOW_PLUGIN_PIP=1 to enable). "
f"File saved to {req_out} for manual review."
)
# Also write the manifest into the backend plugin dir for runtime access
if has_backend:
manifest_out = os.path.join(backend_dest, 'plugin.json')
with open(manifest_out, 'w') as f:
json.dump(manifest, f, indent=2)
# Also write manifest to frontend dir
if has_frontend:
manifest_out = os.path.join(frontend_dest, 'plugin.json')
with open(manifest_out, 'w') as f:
json.dump(manifest, f, indent=2)
# Determine blueprint info from manifest
entry_point = manifest.get('entry_point', '')
url_prefix = manifest.get('url_prefix', f'/api/v1/{slug}')
plugin.has_backend = has_backend
plugin.has_frontend = has_frontend
plugin.backend_path = f'app/plugins/{slug}' if has_backend else None
plugin.frontend_path = f'src/plugins/{slug}' if has_frontend else None
plugin.entry_point = entry_point
plugin.url_prefix = url_prefix
plugin.frontend_entry = manifest.get('frontend_entry', '')
plugin.status = InstalledPlugin.STATUS_ACTIVE
db.session.commit()
# Try to register the blueprint immediately (hot-load)
if has_backend and entry_point and hot_load:
try:
_register_plugin_blueprint(plugin)
except Exception as e:
logger.warning(f'Blueprint hot-load failed for {slug} (will load on restart): {e}')
# Regenerate frontend plugin manifest
if has_frontend:
_regenerate_frontend_manifest()
# Install template dependencies declared in manifest
# (e.g. a "git-server" extension declaring "templates": ["gitea"]).
# Best effort — record per-template result on the plugin row so the
# UI can show what happened, but a template install failure does
# not roll back the plugin install.
template_results = _install_template_dependencies(plugin, manifest)
# Register plugin-owned data models (create ext_<slug>_ tables) and
# declarative jobs/schedules before the install hook runs, so the hook
# can seed rows into those tables. Best-effort (never fail the install).
if has_backend:
try:
from app.services import extension_lifecycle
extension_lifecycle.register_models(plugin, manifest)
extension_lifecycle.register_jobs(plugin, manifest)
except Exception as e:
logger.warning(f'Extension lifecycle setup failed for {slug}: {e}')
try:
from app.plugins_sdk import sockets as _ext_sockets
_ext_sockets.register_from_manifest(plugin, manifest)
except Exception as e:
logger.warning(f'Extension socket setup failed for {slug}: {e}')
# Run plugin's lifecycle.install hook if declared. Failure here is
# also non-fatal — the plugin is installed; the hook is for
# convenience setup (e.g. creating default rows).
_run_lifecycle_hook(plugin, manifest, 'install')
# If this was an in-place version change, run the upgrade hook too.
if previous_version and previous_version != manifest['version']:
_run_lifecycle_hook(plugin, manifest, 'upgrade')
# If the plugin declares an "ai" block, register its tools/context with
# the core assistant now so installing it teaches the assistant new
# abilities without a restart. Best effort.
_refresh_plugin_ai(plugin)
logger.info(f'Plugin {slug} v{manifest["version"]} installed successfully')
if template_results:
plugin._template_install_results = template_results # surfaced via to_dict if needed
return plugin
except Exception as e:
plugin.status = InstalledPlugin.STATUS_ERROR
plugin.error_message = str(e)
db.session.commit()
raise
def _install_requirements(req_content, plugin_name):
"""Install Python requirements for a plugin."""
if not req_content.strip():
return
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write(req_content)
req_path = f.name
try:
logger.info(f'Installing requirements for plugin {plugin_name}')
subprocess.check_call(
[sys.executable, '-m', 'pip', 'install', '-r', req_path, '--quiet'],
timeout=300,
)
except subprocess.CalledProcessError as e:
logger.error(f'Failed to install requirements for {plugin_name}: {e}')
raise ValueError(f'Failed to install Python dependencies: {e}')
finally:
os.unlink(req_path)
def _attach_status_guard(bp, slug):
"""Attach a before_request that 503s if the plugin is disabled.
Flask blueprints can't be unregistered from a running app, so once a
plugin's routes are loaded they stay reachable until restart. This
guard makes the in-DB status authoritative at request time, so
disabling a plugin actually stops serving its routes.
"""
# A blueprint may be mounted at more than one url_prefix (e.g. an alias),
# but the guard only needs to run once per blueprint object.
if getattr(bp, '_sk_status_guarded', False):
return
def _check():
from flask import jsonify
p = InstalledPlugin.query.filter_by(slug=slug).first()
if not p or p.status != InstalledPlugin.STATUS_ACTIVE:
return jsonify({
'error': f"Plugin '{slug}' is not active",
'status': p.status if p else 'uninstalled',
}), 503
return None
bp.before_request(_check)
bp._sk_status_guarded = True
def _register_extra_blueprints(register, plugin, manifest):
"""Register a plugin's ``extra_blueprints`` beyond the single ``entry_point``.
Some extensions (WordPress) expose several blueprints and even mount one
blueprint at more than one prefix (a deprecation-window alias). The manifest
declares them as::
"extra_blueprints": [
{"module": "wordpress_sites", "attr": "wordpress_sites_bp",
"url_prefix": "/api/v1/wordpress"},
{"module": "environment_pipeline", "attr": "environment_pipeline_bp",
"url_prefix": "/api/v1/wordpress/pipelines",
"name": "environment_pipeline_pipelines"}
]
``register`` is a ``callable(bp, url_prefix, name=None)`` — either
``current_app.register_blueprint`` (hot-load) or ``app.register_blueprint``
(boot). Each blueprint gets the same status guard as the entry blueprint.
"""
import importlib
for extra in (manifest or {}).get('extra_blueprints', []):
module_name = extra.get('module')
attr = extra.get('attr')
url_prefix = extra.get('url_prefix')
name = extra.get('name')
if not (module_name and attr):
continue
full_module = f'app.plugins.{plugin.slug}.{module_name}'
mod = importlib.import_module(full_module)
bp = getattr(mod, attr)
_attach_status_guard(bp, plugin.slug)
# Only pass name= when the manifest supplies one (mounting the SAME
# blueprint at a second prefix needs a distinct name, e.g. the WordPress
# /pipelines alias); passing name=None would clobber the blueprint name.
opts = {'url_prefix': url_prefix}
if name:
opts['name'] = name
register(bp, **opts)
logger.info(f'Registered extra blueprint {attr} at {url_prefix}'
+ (f' (as {name})' if name else ''))
def _register_plugin_blueprint(plugin):
"""Dynamically register a plugin's Flask blueprint into the running app."""
from flask import current_app
import importlib
if not plugin.entry_point:
return
# entry_point format: "blueprint:ai_assistant_bp"
parts = plugin.entry_point.split(':')
if len(parts) != 2:
raise ValueError(f'Invalid entry_point format: {plugin.entry_point}')
module_name, bp_name = parts
full_module = f'app.plugins.{plugin.slug}.{module_name}'
try:
mod = importlib.import_module(full_module)
bp = getattr(mod, bp_name)
_attach_status_guard(bp, plugin.slug)
current_app.register_blueprint(bp, url_prefix=plugin.url_prefix)
logger.info(f'Registered blueprint {bp_name} at {plugin.url_prefix}')
_register_extra_blueprints(
current_app.register_blueprint, plugin, plugin.manifest or {})
except Exception as e:
raise ValueError(f'Failed to register blueprint: {e}')
def _regenerate_frontend_manifest():
"""Generate a plugins-manifest.json for the frontend build system.
This file tells the frontend which plugins are installed and where
their components/styles live so Vite can include them.
Best-effort: if the frontend dir doesn't exist (e.g. a pure backend
plugin install where no frontend was ever extracted, or a Docker
panel without the bind mount), we just log and move on rather than
failing the install.
Never runs under the testing config: the test DB only has whatever a given
test installed, so regenerating would clobber the repo's checked-in
plugins-manifest.json with partial state (the flagship seed runs on every
test boot). Tests that care about the manifest monkeypatch the dir.
"""
try:
from flask import current_app
if current_app and current_app.config.get('TESTING'):
return
except Exception:
pass
if not os.path.isdir(FRONTEND_PLUGINS_DIR):
logger.info(
f'Skipping frontend manifest regeneration: '
f'{FRONTEND_PLUGINS_DIR} does not exist'
)
return
manifest_path = os.path.join(FRONTEND_PLUGINS_DIR, 'plugins-manifest.json')
plugins = InstalledPlugin.query.filter(
InstalledPlugin.has_frontend == True,
InstalledPlugin.status.in_(['active']),
).all()
entries = []
for p in plugins:
entry = {
'name': p.name,
'slug': p.slug,
'display_name': p.display_name,
'version': p.version,
'frontend_entry': p.frontend_entry,
'path': p.slug,
}
# Check for styles
style_dir = os.path.join(FRONTEND_PLUGINS_DIR, p.slug, 'styles')
if os.path.isdir(style_dir):
styles = [
f for f in os.listdir(style_dir)
if f.endswith('.scss') or f.endswith('.css') or f.endswith('.less')
]
entry['styles'] = [f'plugins/{p.slug}/styles/{s}' for s in styles]
entries.append(entry)
with open(manifest_path, 'w') as f:
json.dump({'plugins': entries}, f, indent=2)
logger.info(f'Frontend plugin manifest regenerated with {len(entries)} plugin(s)')
def repair_missing_plugins():
"""Self-heal installed plugins whose files are gone (#48).
A panel update deploys a fresh source tree preserving only `.env` and the
database, so a URL/upload-installed plugin's extracted files (and a
copy-installed builtin's backend under ``app/plugins/``) can vanish while
the InstalledPlugin row stays active — the boot loader would then flip it
to `error`. Before the loader runs, restore what we can:
- builtin slug → reinstall from ``builtin-extensions/`` (in-repo,
always present in the fresh tree)
- source_type 'url' → re-download from ``source_url`` (no checksum —
the original install verified it; best effort)
- anything else → mark `error` with a re-upload hint
Reinstalls run with ``hot_load=False`` — load_all_plugins registers the
blueprints right after this pass. Never raises; each repair is
best-effort and logged. The update.sh carry-forward is the first line of
defense; this is the backstop (e.g. Docker image updates).
"""
builtin_by_slug = {}
try:
for entry in list_builtin_extensions():
builtin_by_slug[entry['slug']] = entry
except Exception:
pass
plugins = InstalledPlugin.query.filter(
InstalledPlugin.status.in_([
InstalledPlugin.STATUS_ACTIVE,
InstalledPlugin.STATUS_ERROR,
]),
).all()
for plugin in plugins:
slug = plugin.slug
# Flagships load in-place from builtin-extensions/ (repo-tracked, never
# copy-installed) — nothing on disk to go missing.
if slug in FLAGSHIP_SLUGS:
continue
backend_missing = bool(plugin.has_backend) and not os.path.isdir(
os.path.join(BACKEND_PLUGINS_DIR, slug))
frontend_missing = bool(plugin.has_frontend) and not os.path.isdir(
os.path.join(FRONTEND_PLUGINS_DIR, slug))
if not backend_missing and not frontend_missing:
continue
halves = ' + '.join(h for h, m in (('backend', backend_missing),
('frontend', frontend_missing)) if m)
logger.warning(
f'Plugin {slug}: {halves} files missing (panel update?) — attempting repair')
try:
if slug in builtin_by_slug:
install_from_path(builtin_by_slug[slug]['path'],
user_id=plugin.installed_by,
force=True, hot_load=False)
logger.info(f'Plugin {slug}: restored from builtin-extensions/')
elif plugin.source_type == 'url' and plugin.source_url:
install_from_url(plugin.source_url, user_id=plugin.installed_by,
force=True, hot_load=False)
logger.info(f'Plugin {slug}: reinstalled from {plugin.source_url}')
else:
plugin.status = InstalledPlugin.STATUS_ERROR
plugin.error_message = (
'Plugin files are missing (likely removed by a panel update) '
'and there is no source URL to reinstall from. Re-upload the '
'plugin zip from the Marketplace to restore it.'
)
db.session.commit()
except Exception as e:
logger.error(f'Plugin {slug}: repair failed: {e}')
try:
plugin.status = InstalledPlugin.STATUS_ERROR
plugin.error_message = f'Repair after panel update failed: {e}'
db.session.commit()
except Exception:
db.session.rollback()
def load_all_plugins(app):
"""Load all active plugin blueprints at app startup.
Called from create_app() to register all installed plugin blueprints.
"""
_ensure_dirs()
with app.app_context():
# Restore any install whose files a panel update wiped (#48) BEFORE
# querying/loading, so a repaired plugin loads normally this boot.
try:
repair_missing_plugins()
except Exception as e:
logger.warning(f'Plugin repair pass failed: {e}')
plugins = InstalledPlugin.query.filter_by(
status=InstalledPlugin.STATUS_ACTIVE,
has_backend=True,
).all()
for plugin in plugins:
if not plugin.entry_point:
continue
try:
parts = plugin.entry_point.split(':')
if len(parts) != 2:
continue
module_name, bp_name = parts
full_module = f'app.plugins.{plugin.slug}.{module_name}'
import importlib
mod = importlib.import_module(full_module)
bp = getattr(mod, bp_name)
_attach_status_guard(bp, plugin.slug)
app.register_blueprint(bp, url_prefix=plugin.url_prefix)
logger.info(f'Loaded plugin: {plugin.display_name} v{plugin.version} at {plugin.url_prefix}')
manifest = plugin.manifest or {}
_register_extra_blueprints(app.register_blueprint, plugin, manifest)
# Re-establish the plugin's data models, jobs, and socket
# namespace on boot (install-time registration doesn't survive
# a restart).
try:
from app.services import extension_lifecycle
extension_lifecycle.register_models(plugin, manifest)
extension_lifecycle.register_jobs(plugin, manifest)
from app.plugins_sdk import sockets as _ext_sockets
_ext_sockets.register_from_manifest(plugin, manifest)
except Exception as e:
logger.warning(f'Extension runtime setup for {plugin.slug}: {e}')
except Exception as e:
logger.error(f'Failed to load plugin {plugin.slug}: {e}')
plugin.status = InstalledPlugin.STATUS_ERROR
plugin.error_message = f'Failed to load: {e}'
db.session.commit()
def uninstall_plugin(plugin_id, purge=False):
"""Uninstall a plugin by removing its files and DB record.
`purge=True` also drops the plugin's ``ext_<slug>_`` data tables (keep-data
is the default, mirroring the installer's --purge/--keep-data semantics).
"""
plugin = InstalledPlugin.query.get(plugin_id)
if not plugin:
return False
slug = plugin.slug
manifest = plugin.manifest or {}
# Run lifecycle.uninstall hook *before* we delete the files (the hook
# may need to read its own files). It's told whether the caller asked to
# purge data. Best effort — never block teardown.
_run_lifecycle_hook(plugin, manifest, 'uninstall', purge=purge)
# Tear down declarative schedules, and (only on purge) the data tables.
try:
from app.services import extension_lifecycle
extension_lifecycle.remove_jobs(plugin, manifest)
if purge:
extension_lifecycle.purge_models(plugin)
except Exception as e:
logger.warning(f'Extension lifecycle teardown for {slug}: {e}')
# Drop any AI tools/context this plugin contributed to the assistant.
_unregister_plugin_ai(slug)
if slug in FLAGSHIP_SLUGS:
# Flagships live in-place (backend in builtin-extensions/, frontend
# pre-bundled under src/plugins/ — both tracked in the repo). Never delete
# those; just record the user's choice so the boot seed leaves it out, and
# drop the row (the status guard 503s any still-registered routes).
_set_flagship_uninstalled(slug, True)
else:
# Remove backend files
backend_dest = os.path.join(BACKEND_PLUGINS_DIR, slug)
if os.path.exists(backend_dest):
shutil.rmtree(backend_dest)
# Remove frontend files
frontend_dest = os.path.join(FRONTEND_PLUGINS_DIR, slug)
if os.path.exists(frontend_dest):
shutil.rmtree(frontend_dest)
db.session.delete(plugin)
db.session.commit()
# Regenerate frontend manifest
_regenerate_frontend_manifest()
logger.info(f'Plugin {slug} uninstalled')
return True
def enable_plugin(plugin_id):
"""Enable a disabled plugin."""
plugin = InstalledPlugin.query.get(plugin_id)
if not plugin:
return None
plugin.status = InstalledPlugin.STATUS_ACTIVE
plugin.error_message = None
db.session.commit()
_regenerate_frontend_manifest()
_refresh_plugin_ai(plugin)
# Resume the plugin's scheduled jobs (status-guard parity for background work).
try:
from app.services import extension_lifecycle
extension_lifecycle.resume_jobs(plugin, plugin.manifest or {})
except Exception as e:
logger.warning(f'Could not resume jobs for {plugin.slug}: {e}')
return plugin
def disable_plugin(plugin_id):
"""Disable a plugin without removing files."""
plugin = InstalledPlugin.query.get(plugin_id)
if not plugin:
return None
plugin.status = InstalledPlugin.STATUS_DISABLED
db.session.commit()
_regenerate_frontend_manifest()
_unregister_plugin_ai(plugin.slug)
# Pause the plugin's scheduled jobs so a disabled plugin does no background work.
try:
from app.services import extension_lifecycle
extension_lifecycle.pause_jobs(plugin, plugin.manifest or {})
except Exception as e:
logger.warning(f'Could not pause jobs for {plugin.slug}: {e}')
return plugin
def _refresh_plugin_ai(plugin):
"""Register a plugin's AI tools/context with the core assistant (if it has an
``ai`` manifest block). Idempotent; safe whether or not AI is initialized."""
try:
manifest = plugin.manifest or {}
if isinstance(manifest.get('ai'), dict):
from app.services.ai_tool_registry import ai_tool_registry
ai_tool_registry.reload_plugin(plugin.slug)
except Exception:
logger.warning(f'AI tool refresh failed for plugin {plugin.slug}', exc_info=True)
def _unregister_plugin_ai(slug):
"""Drop a plugin's AI tools/context from the core assistant."""
try:
from app.services.ai_tool_registry import ai_tool_registry
ai_tool_registry.unregister_plugin(slug)
except Exception:
logger.warning(f'AI tool unregister failed for plugin {slug}', exc_info=True)
def _run_lifecycle_hook(plugin, manifest, phase, **kwargs):
"""Execute a plugin's lifecycle hook.
Manifest format:
"lifecycle": { "install": "module:func", "upgrade": "module:func",
"uninstall": "module:func" }
The module path is resolved under ``app.plugins.<slug>``. The hook receives
the InstalledPlugin row as its single positional arg; the uninstall hook may
additionally accept ``purge`` (whether the caller asked to drop data). Hooks
written as ``func(plugin)`` still work — extra kwargs are only passed when the
signature accepts them. Return value is ignored; failure is logged and
swallowed — lifecycle hooks are convenience, not correctness.
"""
lifecycle = (manifest or {}).get('lifecycle') or {}
target = lifecycle.get(phase)
if not target or ':' not in target:
return
module_name, func_name = target.split(':', 1)
full_module = f'app.plugins.{plugin.slug}.{module_name}'
try:
import importlib
import inspect
mod = importlib.import_module(full_module)
func = getattr(mod, func_name, None)
if not callable(func):
logger.warning(
f'Lifecycle {phase} hook {target} for {plugin.slug} is not callable'
)
return
# Only forward kwargs the hook actually declares (keeps func(plugin) valid).
if kwargs:
try:
params = inspect.signature(func).parameters
accepts_kwargs = any(
p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()
)
passable = {k: v for k, v in kwargs.items()
if accepts_kwargs or k in params}
except (TypeError, ValueError):
passable = {}
func(plugin, **passable)
else:
func(plugin)
logger.info(f'Ran lifecycle.{phase} hook for {plugin.slug}')
except Exception as e:
logger.warning(f'Lifecycle {phase} hook for {plugin.slug} failed: {e}')
def _install_template_dependencies(plugin, manifest):
"""Install app templates declared in the manifest's `templates` key.
Format:
"templates": ["gitea", "umami"] # template ids
"templates": [{ "id": "gitea",
"app_name": "git-server", # default: template id
"variables": {"PORT": "3000"} }]
Returns a list of {template_id, success, message} dicts. Failures are
logged but do not abort the plugin install — the user can install the
template manually from /templates if it didn't auto-install.
"""
deps = (manifest or {}).get('templates') or []
if not deps:
return []
try:
from app.services.template_service import TemplateService
except Exception as e:
logger.warning(f'TemplateService unavailable; skipping template deps: {e}')
return []
results = []
for dep in deps:
if isinstance(dep, str):
template_id, app_name, variables = dep, dep, {}
elif isinstance(dep, dict):
template_id = dep.get('id') or dep.get('template_id')
app_name = dep.get('app_name') or template_id
variables = dep.get('variables') or {}
else:
continue
if not template_id:
continue
try:
res = TemplateService.install_template(
template_id=template_id,
app_name=app_name,
user_variables=variables,
user_id=plugin.installed_by,
)
ok = bool(res and res.get('success'))
results.append({
'template_id': template_id,
'success': ok,
'message': res.get('error') if not ok else 'installed',
})
if ok:
logger.info(f'Plugin {plugin.slug} installed template {template_id}')
else:
logger.warning(
f'Plugin {plugin.slug} template dep {template_id} failed: '
f'{res.get("error")}'
)
except Exception as e:
logger.warning(
f'Plugin {plugin.slug} template dep {template_id} raised: {e}'
)
results.append({
'template_id': template_id,
'success': False,
'message': str(e),
})
return results
def list_plugins(status=None):
"""List installed plugins."""
query = InstalledPlugin.query
if status:
query = query.filter_by(status=status)
return query.order_by(InstalledPlugin.display_name).all()
def get_plugin(plugin_id):
"""Get a single plugin by ID."""
return InstalledPlugin.query.get(plugin_id)
def get_plugin_by_slug(slug):
"""Get a plugin by its slug."""
return InstalledPlugin.query.filter_by(slug=slug).first()
def list_builtin_extensions():
"""Enumerate folders in BUILTIN_EXTENSIONS_DIR that look like plugins.
Each entry includes the manifest, the absolute source path, and the
install state (installed/active/disabled/error/not_installed). The
Marketplace UI uses this to render one-click installs.
"""
out = []
if not os.path.isdir(BUILTIN_EXTENSIONS_DIR):
return out
for name in sorted(os.listdir(BUILTIN_EXTENSIONS_DIR)):
folder = os.path.join(BUILTIN_EXTENSIONS_DIR, name)
manifest_path = os.path.join(folder, 'plugin.json')
if not os.path.isfile(manifest_path):
continue
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
except Exception as e:
logger.warning(f'Skipping builtin {name}: bad plugin.json ({e})')
continue
slug = manifest.get('name') or name
existing = InstalledPlugin.query.filter_by(slug=slug).first()
installed_state = existing.status if existing else 'not_installed'
out.append({
'folder': name,
'path': folder,
'slug': slug,
'manifest': manifest,
'installed': existing is not None,
'install_id': existing.id if existing else None,
'status': installed_state,
})
return out
def install_builtin_extension(slug, user_id=None):
"""Install a builtin extension by its slug (folder lookup)."""
if not os.path.isdir(BUILTIN_EXTENSIONS_DIR):
raise ValueError(f'Builtin extensions dir not found: {BUILTIN_EXTENSIONS_DIR}')
# Flagships (D4) re-install in-place: clear the user-uninstall marker and
# re-seed the active row — no file copy, since the backend loads from
# builtin-extensions/ and the frontend is pre-bundled.
if slug in FLAGSHIP_SLUGS:
_set_flagship_uninstalled(slug, False)
for entry in list_builtin_extensions():
if entry['slug'] == slug:
_ensure_builtin_backend_importable(slug)
existing = InstalledPlugin.query.filter_by(slug=slug).first()
if existing:
existing.status = InstalledPlugin.STATUS_ACTIVE
db.session.commit()
return existing
plugin = _seed_flagship_row(entry)
try:
_register_plugin_blueprint(plugin)
except Exception as e:
logger.warning(
f'Flagship blueprint hot-load failed for {slug} '
f'(will load on restart): {e}')
return plugin
raise ValueError(f"No builtin extension with slug '{slug}'")
for entry in list_builtin_extensions():
if entry['slug'] == slug:
return install_from_path(entry['path'], user_id=user_id)
raise ValueError(f"No builtin extension with slug '{slug}'")
# ── Flagship (D4) default-install machinery ──
def _flagship_uninstalled_set():
"""Slugs the user explicitly uninstalled — never re-seeded on boot."""
from app.services.settings_service import SettingsService
raw = SettingsService.get(_FLAGSHIP_UNINSTALLED_KEY, '')
if not raw:
return set()
try:
return set(json.loads(raw))
except (ValueError, TypeError):
return {s.strip() for s in str(raw).split(',') if s.strip()}
def _set_flagship_uninstalled(slug, uninstalled):
from app.services.settings_service import SettingsService
current = _flagship_uninstalled_set()
if uninstalled:
current.add(slug)
else:
current.discard(slug)
SettingsService.set(_FLAGSHIP_UNINSTALLED_KEY, json.dumps(sorted(current)))
def _seed_flagship_row(entry):
"""Create an active InstalledPlugin row for a flagship without copying files.
The backend is loaded in-place from builtin-extensions/ (see
``_ensure_builtin_backend_importable``) and the frontend is pre-bundled under
``frontend/src/plugins/<slug>`` (D5), so the row only records install state
for the Marketplace/contributions surfaces and drives the status guard.
"""
manifest = entry['manifest']
slug = entry['slug']
has_backend = os.path.isdir(os.path.join(entry['path'], 'backend'))
has_frontend = os.path.isdir(os.path.join(entry['path'], 'frontend'))
plugin = InstalledPlugin(
name=manifest['name'],
display_name=manifest.get('display_name', slug),
slug=slug,
version=manifest['version'],
description=manifest.get('description', ''),
author=manifest.get('author', ''),
homepage=manifest.get('homepage', ''),
repository=manifest.get('repository', ''),
license=manifest.get('license', ''),
category=manifest.get('category', 'utility'),
source_type='builtin',
status=InstalledPlugin.STATUS_ACTIVE,
)
plugin.manifest = manifest
plugin.has_backend = has_backend
plugin.has_frontend = has_frontend
plugin.backend_path = f'builtin-extensions/{slug}/backend' if has_backend else None
plugin.frontend_path = f'src/plugins/{slug}' if has_frontend else None
plugin.entry_point = manifest.get('entry_point', '')
plugin.url_prefix = manifest.get('url_prefix', f'/api/v1/{slug}')
plugin.frontend_entry = manifest.get('frontend_entry', '')
db.session.add(plugin)
db.session.commit()
# Register plugin-owned data models / jobs / sockets, same as a copy-install.
try:
from app.services import extension_lifecycle
extension_lifecycle.register_models(plugin, manifest)
extension_lifecycle.register_jobs(plugin, manifest)
from app.plugins_sdk import sockets as _ext_sockets
_ext_sockets.register_from_manifest(plugin, manifest)
except Exception as e:
logger.warning(f'Flagship lifecycle setup for {slug}: {e}')
_refresh_plugin_ai(plugin)
# Only touch the frontend build manifest if the flagship actually ships a
# frontend (WordPress is backend-only — its UI stays core for now). A
# pre-bundled flagship frontend (D5) is already represented in the checked-in
# manifest, so there's nothing to regenerate here.
if plugin.has_frontend:
try:
_regenerate_frontend_manifest()
except Exception as e:
logger.warning(f'Frontend manifest regen after flagship seed for {slug}: {e}')
logger.info(f'Seeded flagship extension row: {slug} v{manifest["version"]}')
return plugin
def seed_flagship_extensions():
"""Ensure bundled flagship extensions (D4) have an active row on EVERY panel
— fresh AND upgrade — unless the user explicitly uninstalled them.
Called from ``create_app`` right before ``load_all_plugins`` so the loader
picks up the seeded row and registers the blueprints (in-place). Best-effort.
"""
uninstalled = _flagship_uninstalled_set()
available = {e['slug']: e for e in list_builtin_extensions()}
for slug in FLAGSHIP_SLUGS:
if slug in uninstalled:
continue
entry = available.get(slug)
if not entry:
continue
# Make the backend importable in-place regardless (the bridge and the
# loader both rely on app.plugins.<slug> resolving).
_ensure_builtin_backend_importable(slug)
existing = InstalledPlugin.query.filter_by(slug=slug).first()
if existing:
# Heal a row left in ERROR by a prior failed load so the flagship
# comes back active on the next boot (the seed is authoritative for a
# flagship the user hasn't uninstalled). A deliberate DISABLE is kept.
if existing.status == InstalledPlugin.STATUS_ERROR:
existing.status = InstalledPlugin.STATUS_ACTIVE
existing.error_message = None
db.session.commit()
continue
try:
_seed_flagship_row(entry)
except Exception as e:
logger.warning(f'Flagship seed for {slug} failed (retry next boot): {e}')
def _assert_panel_compatible(entry):
"""Raise if the panel version is outside an entry's min/max_panel_version."""
from app.utils.version import version_satisfies, get_panel_version
panel = get_panel_version()
minv = entry.get('min_panel_version')
maxv = entry.get('max_panel_version')
if not version_satisfies(panel, minv, maxv):
raise ValueError(
f"{entry.get('display_name') or entry.get('slug')} "
f"v{entry.get('version')} needs panel "
f"{minv or '*'}–{maxv or '*'} (this panel is {panel})."
)
def install_registry_extension(slug, user_id=None):
"""Install an extension listed in the remote registry (checksum-verified)."""
from app.services import registry_service
entry = registry_service.get_entry(slug)
if not entry:
raise ValueError(f"No registry extension with slug '{slug}'")
_assert_panel_compatible(entry)
source = (entry.get('source') or '').strip()
if not source:
raise ValueError(f"Registry entry '{slug}' has no source URL.")
return install_from_url(
source, user_id=user_id, expected_sha256=entry.get('sha256'),
)
def check_for_updates():
"""Compare installed plugins to the registry; return update candidates.
Update v1 is registry-driven: a plugin whose slug is in the registry with a
higher version than installed is flagged. Panel-version compatibility is
surfaced so the UI can gray out an update the panel can't run yet.
"""
from app.services import registry_service
from app.utils.version import compare_versions, version_satisfies, get_panel_version
panel = get_panel_version()
out = []
for p in InstalledPlugin.query.all():
entry = registry_service.get_entry(p.slug)
if not entry:
continue
available = entry.get('version')
out.append({
'slug': p.slug,
'plugin_id': p.id,
'installed_version': p.version,
'available_version': available,
'update_available': compare_versions(available, p.version) > 0,
'compatible': version_satisfies(
panel, entry.get('min_panel_version'), entry.get('max_panel_version')
),
'source': entry.get('source'),
})
return out
def update_plugin(plugin_id, user_id=None):
"""Update an installed plugin to the registry's version (reinstall in place)."""
from app.services import registry_service
plugin = InstalledPlugin.query.get(plugin_id)
if not plugin:
raise ValueError('Plugin not found')
entry = registry_service.get_entry(plugin.slug)
if not entry:
raise ValueError(
f"No registry entry for '{plugin.slug}' — cannot update from the registry."
)
_assert_panel_compatible(entry)
source = (entry.get('source') or '').strip()
if not source:
raise ValueError('Registry entry has no source URL.')
return install_from_url(
source, user_id=user_id, expected_sha256=entry.get('sha256'), force=True,
)