Files
connpy/connpy/services/plugin_service.py
T
fluzzi32 37db74f47d refactor(core): stabilize gRPC streaming, plugin invocation, and CLI UX
- Implement threaded plugin execution with Queue-based streaming in PluginService
- Refactor remote logger to preserve ANSI colors and fix TTY line endings (\r\n)
- Intelligent terminal filtering: disable SSM screen-clearing filter after success
- Sanitize SSH-only flags in core.py when using SFTP protocol
- Rewrite completion tree with pre/post-node states and flag deduplication
- Update gRPC unit tests to match new streaming response structure
2026-05-05 18:24:31 -03:00

277 lines
10 KiB
Python

from .base import BaseService
import yaml
import os
from .exceptions import InvalidConfigurationError, NodeNotFoundError
class PluginService(BaseService):
"""Business logic for enabling, disabling, and listing plugins."""
def list_plugins(self):
"""List all core and user-defined plugins with their status and hash."""
import os
import hashlib
# Check for user plugins directory
plugin_dir = os.path.join(self.config.defaultdir, "plugins")
# Check for core plugins directory
core_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
all_plugin_info = {}
def get_hash(path):
try:
with open(path, "rb") as f:
return hashlib.md5(f.read()).hexdigest()
except Exception:
return ""
# User plugins
if os.path.exists(plugin_dir):
for f in os.listdir(plugin_dir):
if f.endswith(".py"):
name = f[:-3]
path = os.path.join(plugin_dir, f)
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
elif f.endswith(".py.bkp"):
name = f[:-7]
all_plugin_info[name] = {"enabled": False}
return all_plugin_info
def add_plugin(self, name, source_file, update=False):
"""Add or update a plugin from a local file."""
import os
import shutil
from connpy.plugins import Plugins
if not name.isalpha() or not name.islower() or len(name) > 15:
raise InvalidConfigurationError("Plugin name should be lowercase letters up to 15 characters.")
p_manager = Plugins()
# Check for bad script
error = p_manager.verify_script(source_file)
if error:
raise InvalidConfigurationError(f"Invalid plugin script: {error}")
self._save_plugin_file(name, source_file, update, is_path=True)
def add_plugin_from_bytes(self, name, content, update=False):
"""Add or update a plugin from bytes (gRPC)."""
import tempfile
import os
if not name.isalpha() or not name.islower() or len(name) > 15:
raise InvalidConfigurationError("Plugin name should be lowercase letters up to 15 characters.")
# Write to temp file to verify script
with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as tmp:
tmp.write(content)
tmp_path = tmp.name
try:
from connpy.plugins import Plugins
p_manager = Plugins()
error = p_manager.verify_script(tmp_path)
if error:
raise InvalidConfigurationError(f"Invalid plugin script: {error}")
self._save_plugin_file(name, tmp_path, update, is_path=True)
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
def _save_plugin_file(self, name, source, update=False, is_path=True):
import os
import shutil
plugin_dir = os.path.join(self.config.defaultdir, "plugins")
os.makedirs(plugin_dir, exist_ok=True)
target_file = os.path.join(plugin_dir, f"{name}.py")
backup_file = f"{target_file}.bkp"
if not update and (os.path.exists(target_file) or os.path.exists(backup_file)):
raise InvalidConfigurationError(f"Plugin '{name}' already exists.")
try:
if is_path:
shutil.copy2(source, target_file)
else:
with open(target_file, "wb") as f:
f.write(source)
except OSError as e:
raise InvalidConfigurationError(f"Failed to save plugin file: {e}")
def delete_plugin(self, name):
"""Remove a plugin file permanently."""
import os
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
disabled_file = f"{plugin_file}.bkp"
deleted = False
for f in [plugin_file, disabled_file]:
if os.path.exists(f):
try:
os.remove(f)
deleted = True
except OSError as e:
raise InvalidConfigurationError(f"Failed to delete plugin file '{f}': {e}")
if not deleted:
raise InvalidConfigurationError(f"Plugin '{name}' not found.")
def enable_plugin(self, name):
"""Activate a plugin by renaming its backup file."""
import os
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
disabled_file = f"{plugin_file}.bkp"
if os.path.exists(plugin_file):
return False # Already enabled
if not os.path.exists(disabled_file):
raise InvalidConfigurationError(f"Plugin '{name}' not found.")
try:
os.rename(disabled_file, plugin_file)
return True
except OSError as e:
raise InvalidConfigurationError(f"Failed to enable plugin '{name}': {e}")
def disable_plugin(self, name):
"""Deactivate a plugin by renaming it to a backup file."""
import os
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
disabled_file = f"{plugin_file}.bkp"
if os.path.exists(disabled_file):
return False # Already disabled
if not os.path.exists(plugin_file):
raise InvalidConfigurationError(f"Plugin '{name}' not found or is a core plugin.")
try:
os.rename(plugin_file, disabled_file)
return True
except OSError as e:
raise InvalidConfigurationError(f"Failed to disable plugin '{name}': {e}")
def get_plugin_source(self, name):
import os
from ..services.exceptions import InvalidConfigurationError
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py"
if os.path.exists(plugin_file):
target = plugin_file
elif os.path.exists(core_path):
target = core_path
else:
raise InvalidConfigurationError(f"Plugin '{name}' not found")
with open(target, "r") as f:
return f.read()
def invoke_plugin(self, name, args_dict):
import sys, io
from argparse import Namespace
from ..services.exceptions import InvalidConfigurationError
from connpy.plugins import Plugins
class MockApp:
is_mock = True
def __init__(self, config):
from ..core import node, nodes
from ..ai import ai
from ..services.provider import ServiceProvider
self.config = config
self.node = node
self.nodes = nodes
self.ai = ai
self.services = ServiceProvider(config, mode="local")
# Get settings for CLI behavior
settings = self.services.config_svc.get_settings()
self.case = settings.get("case", False)
self.fzf = settings.get("fzf", False)
try:
self.nodes_list = self.services.nodes.list_nodes()
self.folders = self.services.nodes.list_folders()
self.profiles = self.services.profiles.list_profiles()
except Exception:
self.nodes_list = []
self.folders = []
self.profiles = []
args = Namespace(**args_dict)
p_manager = Plugins()
import os
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py"
if os.path.exists(plugin_file):
target = plugin_file
elif os.path.exists(core_path):
target = core_path
else:
raise InvalidConfigurationError(f"Plugin '{name}' not found")
module = p_manager._import_from_path(target)
parser = module.Parser().parser if hasattr(module, "Parser") else None
if "__func_name__" in args_dict and hasattr(module, args_dict["__func_name__"]):
args.func = getattr(module, args_dict["__func_name__"])
app = MockApp(self.config)
from .. import printer
from rich.console import Console
from rich.console import Console
import queue
import threading
q = queue.Queue()
class QueueIO(io.StringIO):
def write(self, s):
q.put(s)
return len(s)
def flush(self):
pass
buf = QueueIO()
old_console = printer._get_console()
old_err_console = printer._get_err_console()
def run_plugin():
printer.set_thread_console(Console(file=buf, theme=printer.connpy_theme, force_terminal=True))
printer.set_thread_err_console(Console(file=buf, theme=printer.connpy_theme, force_terminal=True))
printer.set_thread_stream(buf)
try:
if hasattr(module, "Entrypoint"):
module.Entrypoint(args, parser, app)
except BaseException as e:
if not isinstance(e, SystemExit):
import traceback
printer.err_console.print(traceback.format_exc())
finally:
printer.set_thread_console(old_console)
printer.set_thread_err_console(old_err_console)
printer.set_thread_stream(None)
q.put(None)
t = threading.Thread(target=run_plugin, daemon=True)
t.start()
while True:
item = q.get()
if item is None:
break
yield item