diff --git a/docs/plugin-example.md b/docs/plugin-example.md new file mode 100644 index 0000000..432835a --- /dev/null +++ b/docs/plugin-example.md @@ -0,0 +1,148 @@ +# Example Plugin: Hello World + +This is a simple example of a socx remote plugin. + +## Repository Structure + +``` +hello-world-plugin/ +├── .socx.yaml # Plugin configuration +├── hello.py # Plugin implementation +└── README.md # Documentation +``` + +## .socx.yaml + +```yaml +plugins: + hello: + command: "hello:cli" + short_help: "A simple hello world plugin" + enabled: true + panel: "Example Plugins" + help: | + Say hello to someone! + + This is a simple example plugin that demonstrates + how to create a remote plugin for socx-cli. +``` + +## hello.py + +```python +"""A simple hello world plugin for socx-cli.""" + +import rich_click as click +from rich.console import Console + +console = Console() + +@click.command() +@click.option( + "--name", + default="World", + help="Name to greet" +) +@click.option( + "--style", + type=click.Choice(["simple", "fancy"]), + default="simple", + help="Greeting style" +) +def cli(name: str, style: str): + """Say hello to someone.""" + if style == "fancy": + console.print(f"[bold cyan]✨ Hello, [yellow]{name}[/yellow]! ✨[/bold cyan]") + else: + console.print(f"Hello, {name}!") + +if __name__ == "__main__": + cli() +``` + +## Usage + +### Installing the Plugin + +```bash +# If this plugin were published at github.com/user/hello-world-plugin +socx plugin add hello user/hello-world-plugin + +# Or with a specific version +socx plugin add hello user/hello-world-plugin --ref v1.0.0 +``` + +### Using the Plugin + +```bash +# Simple greeting +socx hello + +# Greet someone specific +socx hello --name Alice + +# Fancy greeting +socx hello --name Bob --style fancy +``` + +### Updating the Plugin + +```bash +socx plugin update hello +``` + +### Removing the Plugin + +```bash +# Remove from project only +socx plugin remove hello + +# Remove from project and cache +socx plugin remove hello --clear-cache +``` + +## Creating Your Own Plugin + +1. **Create a new repository** on GitHub + +2. **Add a `.socx.yaml` file** with your plugin configuration: + ```yaml + plugins: + your-plugin-name: + command: "your_module:cli" + short_help: "Brief description" + enabled: true + ``` + +3. **Implement your plugin** in a Python file: + ```python + import rich_click as click + + @click.command() + def cli(): + """Your plugin logic here.""" + click.echo("Hello from your plugin!") + ``` + +4. **Test locally** by cloning and using file paths: + ```bash + git clone https://github.com/you/your-plugin + cd your-project + # Edit .socx.yaml to add plugin with file path for testing + ``` + +5. **Publish** to GitHub and share with others: + ```bash + socx plugin add your-plugin you/your-plugin + ``` + +## Plugin Best Practices + +1. **Use clear, descriptive names** for your plugins +2. **Provide comprehensive help text** in `.socx.yaml` +3. **Follow Click conventions** for command implementation +4. **Include a README** with usage examples +5. **Use semantic versioning** for tags +6. **Test your plugin** before publishing +7. **Keep dependencies minimal** to avoid conflicts +8. **Document any required environment variables** or setup diff --git a/docs/remote-plugins.md b/docs/remote-plugins.md new file mode 100644 index 0000000..b7ac604 --- /dev/null +++ b/docs/remote-plugins.md @@ -0,0 +1,267 @@ +# Remote Plugin Support + +This document describes the remote plugin feature for socx-cli, which allows you to add, manage, and use plugins from remote Git repositories (for example, GitHub, GitLab, Bitbucket, or self-hosted instances) as well as local filesystem paths. + +## Overview + +Remote plugins are Git repositories (hosted on providers such as GitHub, GitLab, Bitbucket, or self-hosted instances) or local filesystem paths that contain socx plugin configurations and implementations. They are automatically cloned or referenced in your local cache and can be managed on a per-project basis, allowing different projects to use different versions of the same plugin. + +## Cache Structure + +Remote plugins are cached in a multi-project-friendly structure: + +``` +~/.cache/socx/plugins/ +└── {repo_hash}/ + └── {ref}/ + ├── .git/ + ├── .socx.yaml + └── {plugin_files} +``` + +Where: +- `{repo_hash}` is a SHA256 hash of the repository URL (first 16 chars) +- `{ref}` is the git reference (branch, tag, or commit SHA) + +This structure allows multiple projects to use different versions of the same plugin simultaneously. + +## Plugin Configuration + +A remote plugin repository must contain a `.socx.yaml` file at its root with at least one plugin defined: + +```yaml +plugins: + my-plugin: + command: "my_module:cli" + short_help: "Description of my plugin" + enabled: true + panel: "My Plugins" +``` + +## Commands + +### Add a Remote Plugin + +```bash +socx plugin add [--ref ] [--force] +``` + +**Arguments:** +- `name`: Local name for the plugin (how you'll invoke it) +- `remote`: GitHub repository URL or shorthand (e.g., `owner/repo`) + +**Options:** +- `--ref`: Git reference (branch, tag, or commit SHA). Default: `main` +- `--force`: Force re-clone if already cached + +**Example:** +```bash +# Add a plugin from GitHub using shorthand +socx plugin add hello-world user/hello-world-plugin + +# Add a plugin with a specific version from GitHub +socx plugin add hello-world user/hello-world-plugin --ref v1.0.0 + +# Force re-clone from GitHub +socx plugin add hello-world user/hello-world-plugin --force + +# Add a plugin from GitLab using a full URL +socx plugin add gitlab-plugin https://gitlab.com/my-group/my-socx-plugin.git + +# Add a plugin from Bitbucket using a full URL +socx plugin add bitbucket-plugin https://bitbucket.org/my-team/my-socx-plugin.git + +# Add a plugin from a local filesystem path (absolute) +socx plugin add local-plugin /path/to/local/plugin/repo + +# Add a plugin from a local filesystem path (relative to current directory) +socx plugin add local-plugin-relative ./plugins/my-local-plugin +``` + +### Remove a Plugin + +```bash +socx plugin remove [--clear-cache] +``` + +**Arguments:** +- `name`: Name of the plugin to remove + +**Options:** +- `--clear-cache`: Also remove the plugin from cache + +**Example:** +```bash +# Remove from project config only +socx plugin remove hello-world + +# Remove from project config and cache +socx plugin remove hello-world --clear-cache +``` + +### Update a Plugin + +```bash +socx plugin update +``` + +**Arguments:** +- `name`: Name of the plugin to update + +**Example:** +```bash +socx plugin update hello-world +``` + +This will pull the latest changes from the remote repository for the configured ref. + +### List Plugins + +```bash +socx plugin list +``` + +Shows all configured plugins with their type (local/remote), remote URL, ref, and enabled status. + +**Example output:** +``` + Configured Plugins +┏━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━┳━━━━━━━━━┓ +┃ Name ┃ Type ┃ Remote ┃ Ref ┃ Enabled ┃ +┡━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━╇━━━━━━━━━┩ +│ hello-world │ remote │ owner/hello-plugin │ main │ ✓ │ +│ git │ local │ - │ - │ ✓ │ +└─────────────┴────────┴──────────────────────┴──────┴─────────┘ +``` + +## Creating a Remote Plugin + +To create a plugin that can be used remotely: + +1. Create a new GitHub repository +2. Add a `.socx.yaml` configuration file at the root: + +```yaml +plugins: + my-plugin: + command: "plugin_module:cli" + short_help: "My awesome plugin" + enabled: true + help: | + Detailed help text for the plugin. + Can be multiple lines. +``` + +3. Implement your plugin (e.g., `plugin_module.py`): + +```python +"""My plugin implementation.""" +import rich_click as click + +@click.command() +@click.option("--name", default="World", help="Name to greet") +def cli(name): + """My plugin command.""" + click.echo(f"Hello from my plugin, {name}!") +``` + +4. Commit and push to GitHub +5. Users can now add your plugin: + +```bash +socx plugin add my-plugin owner/repo +``` + +## Plugin Schema Extensions + +The `PluginModel` schema has been extended to support remote plugins: + +```python +remote: str = "" # GitHub repository URL or shorthand +ref: str = "" # Git reference (branch, tag, or commit) +``` + +You can check if a plugin is remote using: + +```python +plugin = PluginModel(name="test", remote="owner/repo") +if plugin.is_remote(): + print("This is a remote plugin!") +``` + +## Multi-Project Support + +Each project maintains its own plugin configuration in `.socx.yaml`. Two projects can use different versions of the same plugin: + +**Project A (.socx.yaml):** +```yaml +plugins: + my-plugin: + remote: "owner/repo" + ref: "v1.0.0" + # ... other config +``` + +**Project B (.socx.yaml):** +```yaml +plugins: + my-plugin: + remote: "owner/repo" + ref: "v2.0.0" + # ... other config +``` + +Both versions are cached separately and loaded correctly based on the project context. + +## Technical Details + +### PluginCache + +The `PluginCache` class manages the plugin cache: + +- `get_plugin_path(remote_url, ref)`: Get cache path for a plugin +- `is_cached(remote_url, ref)`: Check if plugin is cached +- `clear_plugin(remote_url, ref)`: Remove plugin from cache + +### PluginManager + +The `PluginManager` class handles plugin operations: + +- `add_plugin(name, remote_url, ref, force)`: Add a remote plugin +- `remove_plugin(name, clear_cache)`: Remove a plugin +- `update_plugin(name)`: Update a plugin to latest version +- `list_plugins()`: List all configured plugins + +### Command Loading + +When a remote plugin is invoked, the `CommandConverter` automatically: + +1. Detects that the plugin is remote via `plugin.is_remote()` +2. Gets the plugin's cache path +3. Adds the cache path to `sys.path` +4. Loads and executes the plugin command + +This happens transparently without user intervention. + +## Testing + +The implementation includes comprehensive tests: + +- `tests/test_plugin_cache.py`: Tests for cache management +- `tests/test_plugin_manager.py`: Tests for plugin operations +- `tests/test_plugin_model.py`: Tests for schema extensions + +Run tests with: +```bash +pytest tests/test_plugin_*.py +``` + +## Error Handling + +The plugin system provides clear error messages: + +- Plugin already exists +- Plugin not found +- Invalid plugin configuration (missing `.socx.yaml`) +- Git clone failures +- Plugin is not remote (when trying to update a local plugin) diff --git a/src/socx/__init__.py b/src/socx/__init__.py index 99e0ec1..1c88944 100644 --- a/src/socx/__init__.py +++ b/src/socx/__init__.py @@ -134,6 +134,9 @@ "TestStatus", "TestResult", "TestCommand", + # Plugins + "PluginCache", + "PluginManager", ) from socx.io import log as log @@ -276,3 +279,6 @@ from socx.regression import TestStatus as TestStatus from socx.regression import TestResult as TestResult from socx.regression import TestCommand as TestCommand + +from socx.plugins import PluginCache as PluginCache +from socx.plugins import PluginManager as PluginManager diff --git a/src/socx/config/converters.py b/src/socx/config/converters.py index 167088c..f77b380 100644 --- a/src/socx/config/converters.py +++ b/src/socx/config/converters.py @@ -634,6 +634,14 @@ def _( if not value.command: return {} + # Handle remote plugins by adding their cache path to sys.path + if value.is_remote(): + from socx.plugins.cache import PluginCache + cache = PluginCache() + plugin_path = cache.get_plugin_path(value.remote, value.ref or "main") + if plugin_path.exists() and str(plugin_path) not in sys.path: + sys.path.insert(0, str(plugin_path)) + if "-h" in sys.argv or "--help" in sys.argv: ctx = click.get_current_context() diff --git a/src/socx/config/schema/plugin.py b/src/socx/config/schema/plugin.py index f680b36..dc45a43 100644 --- a/src/socx/config/schema/plugin.py +++ b/src/socx/config/schema/plugin.py @@ -118,6 +118,24 @@ class PluginModel(BaseModel): description="The short help to use for this command", ) + remote: str = Field( + default="", + description=""" + Git repository URL, shorthand (e.g. owner/repo), or local path for + remote plugins. Supports multiple providers (such as GitHub, GitLab, + Bitbucket, self-hosted Git) and will be cloned or fetched into the + cache directory when specified. + """.strip(), + ) + + ref: str = Field( + default="", + description=""" + Git reference (branch, tag, or commit SHA) for remote plugins. + If left unspecified, uses the default branch. + """.strip(), + ) + model_config = ConfigDict( extra="allow", from_attributes=True, @@ -130,6 +148,9 @@ def is_script(self) -> bool: def is_command(self) -> bool: return bool(self.command) + def is_remote(self) -> bool: + return bool(self.remote) + @classmethod def toml_schema(cls) -> str | None: return SBox(cls.model_json_schema()).toml diff --git a/src/socx/plugins/__init__.py b/src/socx/plugins/__init__.py new file mode 100644 index 0000000..092af98 --- /dev/null +++ b/src/socx/plugins/__init__.py @@ -0,0 +1,8 @@ +"""Plugin management utilities for remote Git plugins from any provider.""" + +from __future__ import annotations + +from socx.plugins.manager import PluginManager +from socx.plugins.cache import PluginCache + +__all__ = ["PluginManager", "PluginCache"] diff --git a/src/socx/plugins/cache.py b/src/socx/plugins/cache.py new file mode 100644 index 0000000..3025934 --- /dev/null +++ b/src/socx/plugins/cache.py @@ -0,0 +1,145 @@ +"""Plugin cache management for multi-project setups.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +from socx.core._paths import USER_CACHE_DIR + + +class PluginCache: + """Manages cached remote plugins with multi-project support. + + The cache structure is organized as: + {USER_CACHE_DIR}/plugins/{repo_hash}/{ref}/ + + where repo_hash is a hash of the repository URL to avoid conflicts + and ref is the git reference (branch, tag, or commit). + """ + + def __init__(self, cache_dir: Path | None = None): + """Initialize plugin cache manager. + + Args: + cache_dir: Optional cache directory override. Defaults to USER_CACHE_DIR/plugins. + """ + self.cache_dir = cache_dir or (USER_CACHE_DIR / "plugins") + self.cache_dir.mkdir(parents=True, exist_ok=True) + + def get_plugin_path(self, remote_url: str, ref: str = "main") -> Path: + """Get the cache path for a plugin. + + Args: + remote_url: Git repository URL, provider shorthand, or local path + ref: Git reference (branch, tag, or commit SHA) + + Returns: + Path to the cached plugin directory + """ + # Normalize the URL + repo_url = self._normalize_url(remote_url) + + # Create a hash of the repo URL to avoid filesystem issues + repo_hash = hashlib.sha256(repo_url.encode()).hexdigest()[:16] + + # Use ref in the path to support multiple versions + plugin_path = self.cache_dir / repo_hash / ref + return plugin_path + + def is_cached(self, remote_url: str, ref: str = "main") -> bool: + """Check if a plugin is already cached. + + Args: + remote_url: Git repository URL, provider shorthand, or local path + ref: Git reference + + Returns: + True if the plugin exists in cache + """ + plugin_path = self.get_plugin_path(remote_url, ref) + return plugin_path.exists() and (plugin_path / ".git").exists() + + def get_config_path(self, remote_url: str, ref: str = "main") -> Path: + """Get the path to the plugin's configuration file. + + Args: + remote_url: Git repository URL, provider shorthand, or local path + ref: Git reference + + Returns: + Path to the plugin's .socx.yaml configuration file + """ + plugin_path = self.get_plugin_path(remote_url, ref) + return plugin_path / ".socx.yaml" + + def clear_plugin(self, remote_url: str, ref: str | None = None) -> None: + """Remove a cached plugin. + + Args: + remote_url: Git repository URL, provider shorthand, or local path + ref: Optional git reference. If None, removes all versions. + """ + repo_url = self._normalize_url(remote_url) + repo_hash = hashlib.sha256(repo_url.encode()).hexdigest()[:16] + + if ref: + plugin_path = self.cache_dir / repo_hash / ref + if plugin_path.exists(): + import shutil + shutil.rmtree(plugin_path) + else: + # Remove all versions + repo_path = self.cache_dir / repo_hash + if repo_path.exists(): + import shutil + shutil.rmtree(repo_path) + + def _normalize_url(self, remote_url: str) -> str: + """Normalize a Git repository URL or path to a canonical form. + + Supports: + - GitHub shorthand (owner/repo) -> https://github.com/owner/repo + - GitLab URLs (gitlab.com/owner/repo) + - Bitbucket URLs (bitbucket.org/owner/repo) + - Full HTTPS URLs (any provider) + - Local filesystem paths (absolute or relative) + + Args: + remote_url: Git repository URL, shorthand, or local path + + Returns: + Normalized URL or path + """ + # If it's a local filesystem path, return as-is (absolute or relative) + # Check for common path patterns: starts with /, ./, ../, or ~ + if remote_url.startswith(("/", "./", "../", "~")): + from pathlib import Path + return str(Path(remote_url).expanduser().resolve()) + + # If it looks like a Windows path (C:\ or similar) + if len(remote_url) > 2 and remote_url[1:2] == ":" and remote_url[2:3] in "\\/": + from pathlib import Path + return str(Path(remote_url).resolve()) + + # If it's a shorthand (owner/repo without protocol), convert to GitHub URL + # Must contain exactly one slash and no dots to avoid catching gitlab.com/owner/repo + if "/" in remote_url and not remote_url.startswith(("http://", "https://", "git@")): + # Check if it's a provider-specific shorthand (e.g., gitlab.com/owner/repo) + parts = remote_url.split("/") + if len(parts) >= 2 and "." in parts[0]: + # It's a domain-based shorthand like gitlab.com/owner/repo + return f"https://{remote_url}" + else: + # It's a GitHub shorthand like owner/repo + return f"https://github.com/{remote_url}" + + # If it's already a full URL, normalize it + if remote_url.startswith("http://"): + remote_url = remote_url.replace("http://", "https://") + + # Remove trailing .git if present + if remote_url.endswith(".git"): + remote_url = remote_url[:-4] + + return remote_url diff --git a/src/socx/plugins/manager.py b/src/socx/plugins/manager.py new file mode 100644 index 0000000..ff5ca9f --- /dev/null +++ b/src/socx/plugins/manager.py @@ -0,0 +1,511 @@ +"""Plugin manager for handling remote Git plugins from any provider.""" + +from __future__ import annotations + +import yaml +from pathlib import Path +from typing import Any + +import pygit2 as git +from pydantic import ValidationError + +from socx.plugins.cache import PluginCache +from socx.core._paths import PROJECT_ROOT_DIR, LOCAL_CONFIG_FILENAME +from socx.config.schema.plugin import PluginModel +from socx.git._ssh import get_ssh_key_path + + +class PluginManager: + """Manages remote Git plugins from GitHub, GitLab, Bitbucket, or local repositories.""" + + def __init__(self, project_root: Path | None = None, use_user_cache: bool = False): + """Initialize plugin manager. + + Args: + project_root: Optional project root directory override + use_user_cache: If True, use USER_CACHE_DIR instead of project .socx directory + """ + self.project_root = project_root or PROJECT_ROOT_DIR + self.config_file = self.project_root / LOCAL_CONFIG_FILENAME + + # Use .socx directory in project root by default, fallback to USER_CACHE_DIR if requested + if use_user_cache: + from socx.core._paths import USER_CACHE_DIR + cache_dir = USER_CACHE_DIR / "plugins" + else: + cache_dir = self.project_root / ".socx" / "plugins" + + self.cache = PluginCache(cache_dir=cache_dir) + + def add_plugin( + self, + name: str, + remote_url: str, + ref: str = "main", + force: bool = False, + ) -> dict[str, Any]: + """Add a remote plugin to the project. + + Args: + name: Name to give the plugin locally + remote_url: Git repository URL, shorthand (owner/repo for GitHub), + provider URL (gitlab.com/owner/repo), or local path + ref: Git reference (branch, tag, or commit SHA) + force: Force re-clone if already cached + + Returns: + Plugin configuration dict + + Raises: + ValueError: If plugin already exists or repo cannot be cloned + """ + # Check if plugin already exists in local config + config = self._load_config() + if name in config.get("plugins", {}): + raise ValueError(f"Plugin '{name}' already exists in local configuration") + + # Clone or update the plugin in cache + plugin_path = self.cache.get_plugin_path(remote_url, ref) + + if force and plugin_path.exists(): + self.cache.clear_plugin(remote_url, ref) + + if not self.cache.is_cached(remote_url, ref): + self._clone_plugin(remote_url, ref, plugin_path) + + # Load plugin configuration from the remote repo + plugin_config = self._load_plugin_config(remote_url, ref) + + # Add remote metadata + plugin_config["remote"] = remote_url + plugin_config["ref"] = ref + plugin_config["name"] = name + + # Add to local config + self._add_to_config(name, plugin_config) + + return plugin_config + + def remove_plugin(self, name: str, clear_cache: bool = False) -> None: + """Remove a plugin from the project. + + Args: + name: Plugin name to remove + clear_cache: If True, also remove from cache + + Raises: + ValueError: If plugin doesn't exist + """ + config = self._load_config() + plugins = config.get("plugins", {}) + + if name not in plugins: + raise ValueError(f"Plugin '{name}' not found in configuration") + + plugin_config = plugins[name] + + # Remove from config + del plugins[name] + config["plugins"] = plugins + self._save_config(config) + + # Optionally clear from cache + if clear_cache and "remote" in plugin_config: + remote_url = plugin_config["remote"] + ref = plugin_config.get("ref", "main") + self.cache.clear_plugin(remote_url, ref) + + def update_plugin(self, name: str) -> dict[str, Any]: + """Update a remote plugin to the latest version. + + Args: + name: Plugin name to update + + Returns: + Updated plugin configuration + + Raises: + ValueError: If plugin doesn't exist or is not remote + """ + config = self._load_config() + plugins = config.get("plugins", {}) + + if name not in plugins: + raise ValueError(f"Plugin '{name}' not found in configuration") + + plugin_config = plugins[name] + + if "remote" not in plugin_config or not plugin_config["remote"]: + raise ValueError(f"Plugin '{name}' is not a remote plugin") + + remote_url = plugin_config["remote"] + ref = plugin_config.get("ref", "main") + + # Get the cached plugin path + plugin_path = self.cache.get_plugin_path(remote_url, ref) + + if not plugin_path.exists(): + # Re-clone if not in cache + self._clone_plugin(remote_url, ref, plugin_path) + else: + # Pull latest changes using pygit2 + try: + repo = git.Repository(str(plugin_path)) + + # Fetch from origin + for remote in repo.remotes: + if remote.name == "origin": + remote.fetch() + break + + # Checkout the ref + ref_obj = None + try: + ref_obj = repo.branches.get(ref) or repo.branches.get(f"origin/{ref}") + except KeyError: + try: + ref_obj = repo.references.get(f"refs/tags/{ref}") + except KeyError: + pass + + if ref_obj: + repo.checkout(ref_obj) + # If it's a branch, merge the remote changes + if ref in repo.branches: + branch = repo.branches[ref] + if branch.upstream: + # Fast-forward merge + repo.head.set_target(branch.upstream.target) + else: + # Try as commit + try: + commit = repo.get(ref) + if commit: + repo.checkout_tree(commit) + repo.set_head(commit.id) + except (KeyError, ValueError): + raise ValueError(f"Reference '{ref}' not found") + + except Exception as e: + raise ValueError(f"Failed to update plugin: {e}") from e + + # Reload plugin configuration + updated_config = self._load_plugin_config(remote_url, ref) + updated_config["remote"] = remote_url + updated_config["ref"] = ref + updated_config["name"] = name + + # Update local config + plugins[name] = updated_config + config["plugins"] = plugins + self._save_config(config) + + return updated_config + + def list_plugins(self) -> dict[str, dict[str, Any]]: + """List all configured plugins. + + Returns: + Dict mapping plugin names to their configurations + """ + config = self._load_config() + return config.get("plugins", {}) + + def _clone_plugin(self, remote_url: str, ref: str, plugin_path: Path) -> None: + """Clone a plugin repository. + + Args: + remote_url: Git repository URL, provider shorthand, or local path + ref: Git reference to checkout + plugin_path: Path to clone into + + Raises: + ValueError: If clone fails + """ + try: + # Normalize URL + normalized_url = self.cache._normalize_url(remote_url) + + # Create parent directory + plugin_path.parent.mkdir(parents=True, exist_ok=True) + + # Set up callbacks for authentication + callbacks = git.RemoteCallbacks() + + # Try to use SSH key if available + ssh_key_path = get_ssh_key_path() + if ssh_key_path and ssh_key_path.exists(): + keypair = git.Keypair( + "git", + str(ssh_key_path) + ".pub", + str(ssh_key_path), + "" + ) + callbacks.credentials = lambda url, username, allowed: keypair + + # Clone the repository + repo = git.clone_repository( + normalized_url, + str(plugin_path), + callbacks=callbacks + ) + + # Checkout the specified ref + ref_obj = None + + # Try to find the reference (branch, tag, or commit) + try: + # Try as a branch first + ref_obj = repo.branches.get(ref) or repo.branches.get(f"origin/{ref}") + except KeyError: + pass + + if ref_obj is None: + try: + # Try as a tag + ref_obj = repo.references.get(f"refs/tags/{ref}") + except KeyError: + pass + + if ref_obj is None: + # Try as a commit SHA + try: + commit = repo.get(ref) + if commit: + repo.checkout_tree(commit) + repo.set_head(commit.id) + return + except (KeyError, ValueError): + pass + + if ref_obj: + repo.checkout(ref_obj) + else: + raise ValueError(f"Reference '{ref}' not found in repository") + + except Exception as e: + raise ValueError(f"Failed to clone plugin from {remote_url}: {e}") from e + + def _load_plugin_config(self, remote_url: str, ref: str) -> dict[str, Any]: + """Load plugin configuration from cached repository. + + Args: + remote_url: Git repository URL or local path + ref: Git reference + + Returns: + Plugin configuration dict + + Raises: + ValueError: If configuration file doesn't exist or validation fails + """ + config_path = self.cache.get_config_path(remote_url, ref) + + if not config_path.exists(): + raise ValueError( + f"Plugin configuration not found at {config_path}. " + "Remote plugins must have a .socx.yaml file in their root." + ) + + # Load configuration using dynaconf to support converters and multiple formats + from dynaconf import Dynaconf + + plugin_path = self.cache.get_plugin_path(remote_url, ref) + + # Load the configuration file with dynaconf to enable converters + settings = Dynaconf( + settings_files=[str(config_path)], + root_path=str(plugin_path), + lowercase_read=True, # Keep lowercase keys + load_dotenv=False, # Don't load .env files + ) + + # Extract the first plugin from the config file + plugins = settings.get("plugins", {}) + if not plugins: + raise ValueError("Plugin configuration must define at least one plugin") + + # Get the first plugin and validate it + first_plugin_name = next(iter(plugins.keys())) + plugin_data = plugins[first_plugin_name] + + # Convert DynaBox to dict if needed + if hasattr(plugin_data, 'to_dict'): + plugin_data = plugin_data.to_dict() + elif not isinstance(plugin_data, dict): + plugin_data = dict(plugin_data) + + # Validate the plugin configuration using PluginModel + try: + # Add the name to the plugin data if not present + if "name" not in plugin_data: + plugin_data["name"] = first_plugin_name + + # Validate using Pydantic model to ensure no malicious content + validated_plugin = PluginModel.model_validate(plugin_data) + + # Return validated plugin as dict + return validated_plugin.model_dump(exclude_none=True) + except ValidationError as e: + raise ValueError( + f"Plugin configuration validation failed: {e}" + ) from e + + def _load_config(self) -> dict[str, Any]: + """Load the project's local configuration file. + + Returns: + Configuration dict + """ + if not self.config_file.exists(): + return {} + + # Load configuration using dynaconf to support converters and multiple formats + from dynaconf import Dynaconf + + settings = Dynaconf( + settings_files=[str(self.config_file)], + root_path=str(self.project_root), + lowercase_read=True, # Keep lowercase keys + load_dotenv=False, # Don't load .env files + ) + + # Convert to dict, filtering out dynaconf internal keys + all_data = settings.as_dict(internal=False) + config = {k: v for k, v in all_data.items() if not k.isupper() or k == "plugins"} + + # Ensure proper case for plugins key + if "PLUGINS" in all_data and "plugins" not in config: + config["plugins"] = all_data["PLUGINS"] + + # Validate plugins if they exist + plugins_key = "plugins" + if plugins_key in config: + plugins = config.get(plugins_key, {}) + if hasattr(plugins, 'to_dict'): + plugins = plugins.to_dict() + elif not isinstance(plugins, dict): + plugins = dict(plugins) + + for plugin_name, plugin_data in plugins.items(): + # Convert DynaBox to dict if needed + if hasattr(plugin_data, 'to_dict'): + plugin_data = plugin_data.to_dict() + elif not isinstance(plugin_data, dict): + plugin_data = dict(plugin_data) + + try: + # Ensure the plugin has a name + if "name" not in plugin_data: + plugin_data["name"] = plugin_name + + # Validate using PluginModel + PluginModel.model_validate(plugin_data) + except ValidationError as e: + raise ValueError( + f"Plugin '{plugin_name}' configuration validation failed: {e}" + ) from e + + return config + + def _save_config(self, config: dict[str, Any]) -> None: + """Save the project's local configuration file. + + Uses append-based saving to preserve user comments and converter definitions. + + Args: + config: Configuration dict to save + """ + self.config_file.parent.mkdir(parents=True, exist_ok=True) + + # Convert Path objects to strings recursively + def convert_paths(obj): + if isinstance(obj, Path): + return str(obj) + elif isinstance(obj, dict): + return {k: convert_paths(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [convert_paths(item) for item in obj] + elif isinstance(obj, tuple): + return tuple(convert_paths(item) for item in obj) + return obj + + config = convert_paths(config) + + # Validate plugins in config before saving + if "plugins" in config: + for plugin_name, plugin_data in config["plugins"].items(): + try: + # Ensure the plugin has a name + if "name" not in plugin_data: + plugin_data["name"] = plugin_name + + # Validate using PluginModel + PluginModel.model_validate(plugin_data) + except ValidationError as e: + raise ValueError( + f"Plugin '{plugin_name}' configuration validation failed: {e}" + ) from e + + # Use ruamel.yaml to preserve comments and formatting + from ruamel.yaml import YAML + + yaml_handler = YAML() + yaml_handler.preserve_quotes = True + yaml_handler.default_flow_style = False + yaml_handler.width = 4096 # Avoid line wrapping + + if self.config_file.exists(): + # Load existing config preserving comments + with open(self.config_file, "r") as f: + existing_data = yaml_handler.load(f) + + if existing_data is None: + existing_data = {} + + # Merge plugin configurations - only update what's new or changed + if "plugins" in config: + if "plugins" not in existing_data: + existing_data["plugins"] = {} + + # First, remove plugins that are not in the new config + existing_plugin_names = list(existing_data.get("plugins", {}).keys()) + new_plugin_names = list(config["plugins"].keys()) + + for existing_name in existing_plugin_names: + if existing_name not in new_plugin_names: + del existing_data["plugins"][existing_name] + + # Then add/update plugins from the new config + for plugin_name, plugin_data in config["plugins"].items(): + # Remove None values before saving + cleaned_data = {k: v for k, v in plugin_data.items() if v is not None} + existing_data["plugins"][plugin_name] = cleaned_data + + # Update other top-level keys + for key, value in config.items(): + if key != "plugins": + existing_data[key] = value + + # Write back preserving structure + with open(self.config_file, "w") as f: + yaml_handler.dump(existing_data, f) + else: + # New file - write directly + with open(self.config_file, "w") as f: + yaml_handler.dump(config, f) + + def _add_to_config(self, name: str, plugin_config: dict[str, Any]) -> None: + """Add a plugin to the local configuration. + + Args: + name: Plugin name + plugin_config: Plugin configuration dict + """ + config = self._load_config() + + if "plugins" not in config: + config["plugins"] = {} + + config["plugins"][name] = plugin_config + self._save_config(config) diff --git a/src/socx_plugins/plugin/__init__.py b/src/socx_plugins/plugin/__init__.py index ab39801..0f6332e 100644 --- a/src/socx_plugins/plugin/__init__.py +++ b/src/socx_plugins/plugin/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import rich_click as click from socx import group @@ -28,3 +29,108 @@ def schema(): syntax = Syntax(schema, "yaml", theme="ansi_dark", tab_size=2) console.print(syntax) + + +@cli.command() +@click.argument("name") +@click.argument("remote") +@click.option("--ref", default="main", help="Git reference (branch, tag, or commit)") +@click.option("--force", is_flag=True, help="Force re-clone if already cached") +def add(name: str, remote: str, ref: str, force: bool): + """Add a plugin from a Git repository or local path. + + NAME: Local name for the plugin + REMOTE: Git repository URL, shorthand (owner/repo), or local path + """ + from socx import console + from socx.plugins.manager import PluginManager + + try: + manager = PluginManager() + plugin_config = manager.add_plugin(name, remote, ref, force) + console.print(f"[green]✓[/green] Plugin '{name}' added successfully") + console.print(f" Remote: {plugin_config.get('remote')}") + console.print(f" Ref: {plugin_config.get('ref')}") + except Exception as e: + console.print(f"[red]✗[/red] Failed to add plugin: {e}") + raise click.Abort() + + +@cli.command() +@click.argument("name") +@click.option("--clear-cache", is_flag=True, help="Also remove from cache") +def remove(name: str, clear_cache: bool): + """Remove a plugin from the project. + + NAME: Name of the plugin to remove + """ + from socx import console + from socx.plugins.manager import PluginManager + + try: + manager = PluginManager() + manager.remove_plugin(name, clear_cache) + console.print(f"[green]✓[/green] Plugin '{name}' removed successfully") + if clear_cache: + console.print(" Cache cleared") + except Exception as e: + console.print(f"[red]✗[/red] Failed to remove plugin: {e}") + raise click.Abort() + + +@cli.command() +@click.argument("name") +def update(name: str): + """Update a remote plugin to the latest version. + + NAME: Name of the plugin to update + """ + from socx import console + from socx.plugins.manager import PluginManager + + try: + manager = PluginManager() + plugin_config = manager.update_plugin(name) + console.print(f"[green]✓[/green] Plugin '{name}' updated successfully") + console.print(f" Remote: {plugin_config.get('remote')}") + console.print(f" Ref: {plugin_config.get('ref')}") + except Exception as e: + console.print(f"[red]✗[/red] Failed to update plugin: {e}") + raise click.Abort() + + +@cli.command("list") +def list_plugins(): + """List all configured plugins.""" + from socx import console + from socx.plugins.manager import PluginManager + from rich.table import Table + + try: + manager = PluginManager() + plugins = manager.list_plugins() + + if not plugins: + console.print("[yellow]No plugins configured[/yellow]") + return + + table = Table(title="Configured Plugins") + table.add_column("Name", style="cyan") + table.add_column("Type", style="magenta") + table.add_column("Remote", style="blue") + table.add_column("Ref", style="green") + table.add_column("Enabled", style="yellow") + + for name, config in plugins.items(): + plugin_type = "remote" if config.get("remote") else "local" + remote = config.get("remote", "-") + ref = config.get("ref", "-") + enabled = "✓" if config.get("enabled", True) else "✗" + table.add_row(name, plugin_type, remote, ref, enabled) + + console.print(table) + + except Exception as e: + console.print(f"[red]✗[/red] Failed to list plugins: {e}") + raise click.Abort() + diff --git a/tests/test_plugin_cache.py b/tests/test_plugin_cache.py new file mode 100644 index 0000000..21317c7 --- /dev/null +++ b/tests/test_plugin_cache.py @@ -0,0 +1,168 @@ +"""Tests for plugin cache management.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest + +from socx.plugins.cache import PluginCache + + +@pytest.fixture +def temp_cache_dir(): + """Create a temporary cache directory for testing.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + +def test_plugin_cache_initialization(temp_cache_dir): + """Test PluginCache initialization.""" + cache = PluginCache(cache_dir=temp_cache_dir) + assert cache.cache_dir == temp_cache_dir + assert cache.cache_dir.exists() + + +def test_normalize_url(): + """Test URL normalization.""" + cache = PluginCache() + + # Test GitHub shorthand + assert cache._normalize_url("owner/repo") == "https://github.com/owner/repo" + + # Test GitHub full URL + assert ( + cache._normalize_url("https://github.com/owner/repo") + == "https://github.com/owner/repo" + ) + + # Test http to https conversion + assert ( + cache._normalize_url("http://github.com/owner/repo") + == "https://github.com/owner/repo" + ) + + # Test .git suffix removal + assert ( + cache._normalize_url("https://github.com/owner/repo.git") + == "https://github.com/owner/repo" + ) + + # Test GitLab URL with domain + assert ( + cache._normalize_url("gitlab.com/owner/repo") + == "https://gitlab.com/owner/repo" + ) + + # Test Bitbucket URL with domain + assert ( + cache._normalize_url("bitbucket.org/owner/repo") + == "https://bitbucket.org/owner/repo" + ) + + # Test full GitLab URL + assert ( + cache._normalize_url("https://gitlab.com/owner/repo") + == "https://gitlab.com/owner/repo" + ) + + # Test full Bitbucket URL + assert ( + cache._normalize_url("https://bitbucket.org/owner/repo") + == "https://bitbucket.org/owner/repo" + ) + + # Test local absolute path (Unix) + import os + if os.name != 'nt': # Unix-like systems + normalized = cache._normalize_url("/home/user/my-plugin") + assert normalized.startswith("/") + assert "my-plugin" in normalized + + # Test local relative path + normalized = cache._normalize_url("./my-plugin") + assert "my-plugin" in normalized + + # Test home directory path + import pathlib + normalized = cache._normalize_url("~/my-plugin") + assert "my-plugin" in normalized + # Should be expanded to absolute path + assert not normalized.startswith("~") + + +def test_get_plugin_path(temp_cache_dir): + """Test plugin path generation.""" + cache = PluginCache(cache_dir=temp_cache_dir) + + path1 = cache.get_plugin_path("owner/repo", "main") + path2 = cache.get_plugin_path("owner/repo", "main") + + # Same inputs should produce same path + assert path1 == path2 + + # Different refs should produce different paths + path3 = cache.get_plugin_path("owner/repo", "v1.0") + assert path3 != path1 + + # Path should be under cache_dir + assert path1.is_relative_to(temp_cache_dir) + + +def test_is_cached(temp_cache_dir): + """Test cache existence check.""" + cache = PluginCache(cache_dir=temp_cache_dir) + + # Initially not cached + assert not cache.is_cached("owner/repo", "main") + + # Create a mock cached plugin + plugin_path = cache.get_plugin_path("owner/repo", "main") + plugin_path.mkdir(parents=True, exist_ok=True) + (plugin_path / ".git").mkdir() + + # Now should be cached + assert cache.is_cached("owner/repo", "main") + + +def test_get_config_path(temp_cache_dir): + """Test config path generation.""" + cache = PluginCache(cache_dir=temp_cache_dir) + + config_path = cache.get_config_path("owner/repo", "main") + assert config_path.name == ".socx.yaml" + assert config_path.parent == cache.get_plugin_path("owner/repo", "main") + + +def test_clear_plugin(temp_cache_dir): + """Test plugin clearing.""" + cache = PluginCache(cache_dir=temp_cache_dir) + + # Create mock cached plugins + plugin_path1 = cache.get_plugin_path("owner/repo", "main") + plugin_path2 = cache.get_plugin_path("owner/repo", "v1.0") + plugin_path1.mkdir(parents=True) + plugin_path2.mkdir(parents=True) + + # Clear specific ref + cache.clear_plugin("owner/repo", "main") + assert not plugin_path1.exists() + assert plugin_path2.exists() + + # Clear all refs + cache.clear_plugin("owner/repo") + assert not plugin_path2.exists() + + +def test_multi_project_support(temp_cache_dir): + """Test that different projects can use different versions.""" + cache = PluginCache(cache_dir=temp_cache_dir) + + # Two different refs should have different paths + path_main = cache.get_plugin_path("owner/repo", "main") + path_v1 = cache.get_plugin_path("owner/repo", "v1.0") + + assert path_main != path_v1 + assert path_main.is_relative_to(temp_cache_dir) + assert path_v1.is_relative_to(temp_cache_dir) diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py new file mode 100644 index 0000000..e4130ce --- /dev/null +++ b/tests/test_plugin_manager.py @@ -0,0 +1,243 @@ +"""Tests for plugin manager.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path +from unittest.mock import Mock, patch, MagicMock + +import pytest +import yaml + +from socx.plugins.manager import PluginManager + + +@pytest.fixture +def temp_project_dir(): + """Create a temporary project directory for testing.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + +@pytest.fixture +def temp_cache_dir(): + """Create a temporary cache directory for testing.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + +@pytest.fixture +def mock_manager(temp_project_dir, temp_cache_dir): + """Create a PluginManager with a temporary project root and cache.""" + from socx.plugins.cache import PluginCache + manager = PluginManager(project_root=temp_project_dir) + # Override the cache to use a temporary directory + manager.cache = PluginCache(cache_dir=temp_cache_dir) + return manager + + +def test_manager_initialization(temp_project_dir): + """Test PluginManager initialization.""" + manager = PluginManager(project_root=temp_project_dir) + assert manager.project_root == temp_project_dir + assert manager.config_file == temp_project_dir / ".socx.yaml" + + +def test_load_config_empty(mock_manager): + """Test loading config when file doesn't exist.""" + config = mock_manager._load_config() + assert config == {} + + +def test_save_and_load_config(mock_manager): + """Test saving and loading config.""" + test_config = {"plugins": {"test": {"name": "test", "enabled": True}}} + + mock_manager._save_config(test_config) + loaded_config = mock_manager._load_config() + + assert loaded_config == test_config + + +def test_list_plugins_empty(mock_manager): + """Test listing plugins when none are configured.""" + plugins = mock_manager.list_plugins() + assert plugins == {} + + +def test_list_plugins_with_data(mock_manager): + """Test listing plugins with data.""" + config = { + "plugins": { + "test-plugin": {"name": "test-plugin", "enabled": True}, + "another-plugin": {"name": "another-plugin", "enabled": False}, + } + } + mock_manager._save_config(config) + + plugins = mock_manager.list_plugins() + assert len(plugins) == 2 + assert "test-plugin" in plugins + assert "another-plugin" in plugins + + +@patch("socx.plugins.manager.git.clone_repository") +def test_add_plugin_success(mock_git_clone, mock_manager, temp_project_dir): + """Test successfully adding a remote plugin.""" + # Mock the git clone operation + mock_repo_instance = MagicMock() + mock_git_clone.return_value = mock_repo_instance + + # Create a mock plugin config in the cache + cache_path = mock_manager.cache.get_plugin_path("owner/repo", "main") + cache_path.mkdir(parents=True, exist_ok=True) + (cache_path / ".git").mkdir() + + plugin_config_path = cache_path / ".socx.yaml" + plugin_config = { + "plugins": { + "example": { + "command": "example:cli", + "short_help": "An example plugin", + "enabled": True, + } + } + } + with open(plugin_config_path, "w") as f: + yaml.safe_dump(plugin_config, f) + + # Add the plugin + result = mock_manager.add_plugin("my-plugin", "owner/repo", "main") + + assert result["name"] == "my-plugin" + assert result["remote"] == "owner/repo" + assert result["ref"] == "main" + + # Verify it was added to config + plugins = mock_manager.list_plugins() + assert "my-plugin" in plugins + + +def test_add_plugin_already_exists(mock_manager): + """Test adding a plugin that already exists.""" + # Add a plugin first + config = {"plugins": {"test-plugin": {"name": "test-plugin"}}} + mock_manager._save_config(config) + + # Try to add it again + with pytest.raises(ValueError, match="already exists"): + mock_manager.add_plugin("test-plugin", "owner/repo", "main") + + +@patch("socx.plugins.manager.git.Repository") +def test_remove_plugin_success(mock_git_repo, mock_manager): + """Test successfully removing a plugin.""" + # Setup: add a plugin first + config = { + "plugins": { + "test-plugin": { + "name": "test-plugin", + "remote": "owner/repo", + "ref": "main", + } + } + } + mock_manager._save_config(config) + + # Remove the plugin + mock_manager.remove_plugin("test-plugin") + + # Verify it was removed + plugins = mock_manager.list_plugins() + assert "test-plugin" not in plugins + + +def test_remove_plugin_not_found(mock_manager): + """Test removing a plugin that doesn't exist.""" + with pytest.raises(ValueError, match="not found"): + mock_manager.remove_plugin("nonexistent") + + +@patch("socx.plugins.manager.git.Repository") +def test_update_plugin_success(mock_git_repo, mock_manager): + """Test successfully updating a plugin.""" + # Setup: add a plugin first + cache_path = mock_manager.cache.get_plugin_path("owner/repo", "main") + cache_path.mkdir(parents=True, exist_ok=True) + (cache_path / ".git").mkdir(exist_ok=True) + + plugin_config_path = cache_path / ".socx.yaml" + plugin_config = { + "plugins": { + "example": { + "command": "example:cli", + "short_help": "Updated plugin", + "enabled": True, + } + } + } + with open(plugin_config_path, "w") as f: + yaml.safe_dump(plugin_config, f) + + config = { + "plugins": { + "test-plugin": { + "name": "test-plugin", + "remote": "owner/repo", + "ref": "main", + "command": "old:cli", + } + } + } + mock_manager._save_config(config) + + # Mock the git operations + mock_repo_instance = MagicMock() + mock_repo_instance.remotes = [] # No remotes, so update will skip fetch + mock_git_repo.return_value = mock_repo_instance + + # Update the plugin + result = mock_manager.update_plugin("test-plugin") + + assert result["name"] == "test-plugin" + assert result["remote"] == "owner/repo" + assert result["ref"] == "main" + + +def test_update_plugin_not_found(mock_manager): + """Test updating a plugin that doesn't exist.""" + with pytest.raises(ValueError, match="not found"): + mock_manager.update_plugin("nonexistent") + + +def test_update_plugin_not_remote(mock_manager): + """Test updating a plugin that is not remote.""" + # Setup: add a local plugin + config = { + "plugins": { + "local-plugin": {"name": "local-plugin", "command": "local:cli"} + } + } + mock_manager._save_config(config) + + with pytest.raises(ValueError, match="not a remote plugin"): + mock_manager.update_plugin("local-plugin") + + +def test_load_plugin_config_missing(mock_manager): + """Test loading plugin config when file doesn't exist.""" + with pytest.raises(ValueError, match="configuration not found"): + mock_manager._load_plugin_config("owner/repo", "main") + + +def test_load_plugin_config_no_plugins(mock_manager): + """Test loading plugin config with no plugins defined.""" + cache_path = mock_manager.cache.get_plugin_path("owner/repo", "main") + cache_path.mkdir(parents=True, exist_ok=True) + + plugin_config_path = cache_path / ".socx.yaml" + with open(plugin_config_path, "w") as f: + yaml.safe_dump({}, f) + + with pytest.raises(ValueError, match="must define at least one plugin"): + mock_manager._load_plugin_config("owner/repo", "main") diff --git a/tests/test_plugin_model.py b/tests/test_plugin_model.py new file mode 100644 index 0000000..e66b5b9 --- /dev/null +++ b/tests/test_plugin_model.py @@ -0,0 +1,83 @@ +"""Tests for PluginModel schema.""" + +from __future__ import annotations + +from socx.config.schema.plugin import PluginModel + + +def test_plugin_model_basic(): + """Test basic PluginModel creation.""" + plugin = PluginModel(name="test-plugin", command="test:cli") + + assert plugin.name == "test-plugin" + assert plugin.command == "test:cli" + assert plugin.enabled is True + assert plugin.is_command() is True + assert plugin.is_script() is False + assert plugin.is_remote() is False + + +def test_plugin_model_with_remote(): + """Test PluginModel with remote source.""" + plugin = PluginModel( + name="remote-plugin", + command="plugin:cli", + remote="owner/repo", + ref="v1.0.0", + ) + + assert plugin.name == "remote-plugin" + assert plugin.remote == "owner/repo" + assert plugin.ref == "v1.0.0" + assert plugin.is_remote() is True + + +def test_plugin_model_script(): + """Test PluginModel with script.""" + plugin = PluginModel(name="script-plugin", script="./my-script.sh") + + assert plugin.name == "script-plugin" + # Script validator adds shebang if not present + assert plugin.script == "#!/bin/sh\n./my-script.sh" + assert plugin.is_script() is True + assert plugin.is_command() is False + + +def test_plugin_model_defaults(): + """Test PluginModel default values.""" + plugin = PluginModel(name="test") + + assert plugin.enabled is True + assert plugin.fresh_env is False + assert plugin.timeout is None + assert plugin.remote == "" + assert plugin.ref == "" + assert plugin.help == "" + assert plugin.panel == "Plugins" + assert plugin.aliases == () + + +def test_plugin_model_with_metadata(): + """Test PluginModel with full metadata.""" + plugin = PluginModel( + name="full-plugin", + command="plugin:cli", + help="Full help text", + short_help="Short help", + panel="Custom Panel", + aliases=("fp", "full"), + enabled=False, + timeout=30.0, + remote="github.com/owner/repo", + ref="main", + ) + + assert plugin.name == "full-plugin" + assert plugin.help == "Full help text" + assert plugin.short_help == "Short help" + assert plugin.panel == "Custom Panel" + assert plugin.aliases == ("fp", "full") + assert plugin.enabled is False + assert plugin.timeout == 30.0 + assert plugin.remote == "github.com/owner/repo" + assert plugin.ref == "main"