"""Plugin install, update and uninstall, and the plugin store and saved repositories. Routes decorate the shared `api_v3` Blueprint from the package `__init__`, so their endpoint names do not depend on which module they live in. """ from web_interface.blueprints.api_v3 import ( ErrorCode, OperationType, Path, _do_transactional_uninstall, _non_plugin_id_error, _get_plugin_version, _plugin_directory, api_v3, datetime, error_response, exception_error_response, json, jsonify, logger, request, success_response, validate_request_json, ) from src.common.path_safety import resolve_under, safe_path_component from typing import Optional def _listed_plugin_dir(base: Path, name: str) -> Optional[Path]: """The entry of ``base`` called ``name``, or None. The path returned comes from listing ``base``, not from joining ``name`` onto it, so a caller that validated ``name`` doesn't have to rely on that validation alone: nothing reaches the filesystem unless it's already there. """ try: for entry in base.iterdir(): if entry.name == name: return entry except OSError: pass return None @api_v3.route('/plugins/update', methods=['POST']) def update_plugin(): """Update plugin""" try: # Support both JSON and form data content_type = request.content_type or '' if 'application/json' in content_type: # JSON request data, error = validate_request_json(['plugin_id']) if error: logger.debug("[UPDATE] JSON validation failed. Content-Type: %s", content_type) return error else: # Form data or query string plugin_id = request.args.get('plugin_id') or request.form.get('plugin_id') if not plugin_id: logger.debug("[UPDATE] Missing plugin_id. Content-Type: %s", content_type) return error_response( ErrorCode.INVALID_INPUT, 'plugin_id required', status_code=400 ) data = {'plugin_id': plugin_id} # /plugins/installed lists installed Starlark apps as virtual # 'starlark:' entries. They are not plugin directories, so the # store manager can only fail to find them -- which used to surface as # a 500 "plugin not found" for an app that is installed and working. raw_id = data.get('plugin_id') if isinstance(raw_id, str) and raw_id.startswith('starlark:'): return error_response( ErrorCode.INVALID_INPUT, f'{raw_id} is a Starlark app, not a plugin; Starlark apps are ' 'not updated through the plugin updater', status_code=400 ) if not api_v3.plugin_store_manager: return error_response( ErrorCode.SYSTEM_ERROR, 'Plugin store manager not initialized', status_code=500 ) # The id names a directory this handler reads a manifest out of and # hands to the store manager to run git in. It comes from the request # body, so it is validated before anything is joined to a path, and # the validated value -- not the raw one -- is what gets used below. plugin_id = safe_path_component(data['plugin_id']) if not plugin_id: return error_response( ErrorCode.INVALID_INPUT, 'Invalid plugin_id', status_code=400 ) # Always do direct updates (they're fast git pull operations) # Operation queue is reserved for longer operations like install/uninstall # The resolver finds a plugin installed as ledmatrix-. Either way # the directory used is taken from a listing of plugins_dir, matched by # name, never built from the request value -- so no path here depends # on user input. None means nothing by that name is installed; the # store manager still gets the id and reports that itself. resolved = _plugin_directory(plugin_id) plugin_dir = _listed_plugin_dir( Path(api_v3.plugin_store_manager.plugins_dir), resolved.name if resolved else plugin_id) manifest_path = None if plugin_dir is not None: manifest_path = resolve_under(plugin_dir, "manifest.json") if manifest_path is None: return error_response( ErrorCode.INVALID_INPUT, 'Invalid plugin_id', status_code=400 ) current_last_updated = None current_version = None current_commit = None current_branch = None if manifest_path is not None and manifest_path.exists(): try: with open(manifest_path, 'r', encoding='utf-8') as f: manifest = json.load(f) current_last_updated = manifest.get('last_updated') current_version = manifest.get('version') if manifest.get('local_only'): logger.debug("Skipping update for local-only plugin: %s", plugin_id) if api_v3.operation_history: api_v3.operation_history.record_operation( "update", plugin_id=plugin_id, status="skipped", details={"reason": "local_only"} ) return success_response( data={'update_status': 'local_only'}, message=f'Plugin {plugin_id} is managed locally and does not receive registry updates') except Exception as e: logger.debug("Could not read local manifest for plugin: %s", e) git_info_before = (api_v3.plugin_store_manager._get_local_git_info(plugin_dir) if plugin_dir is not None else None) if git_info_before: current_commit = git_info_before.get('sha') current_branch = git_info_before.get('branch') logger.debug("Plugin is a git repository, will update via git pull") remote_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id, fetch_latest_from_github=True) remote_commit = remote_info.get('last_commit_sha') if remote_info else None remote_branch = remote_info.get('branch') if remote_info else None # Update the plugin success = api_v3.plugin_store_manager.update_plugin(plugin_id) if success: updated_last_updated = current_last_updated updated_version = current_version try: if manifest_path is not None and manifest_path.exists(): with open(manifest_path, 'r', encoding='utf-8') as f: manifest = json.load(f) updated_last_updated = manifest.get('last_updated', current_last_updated) updated_version = manifest.get('version', current_version) except Exception as e: logger.debug("Could not read updated manifest after update: %s", e) updated_commit = None updated_branch = remote_branch or current_branch git_info_after = (api_v3.plugin_store_manager._get_local_git_info(plugin_dir) if plugin_dir is not None else None) if git_info_after: updated_commit = git_info_after.get('sha') updated_branch = git_info_after.get('branch') or updated_branch # update_plugin() answers True for "nothing to do" as well as for # a real update (a ZIP-installed monorepo plugin already at the # registry version, a bundled plugin), so what changed is read off # the plugin itself: its git commit, else its manifest. update_status = 'updated' message = f'Plugin {plugin_id} updated successfully' if current_commit and updated_commit and current_commit == updated_commit: update_status = 'up_to_date' message = f'Plugin {plugin_id} already up to date (commit {updated_commit[:7]})' elif updated_commit: message = f'Plugin {plugin_id} updated to commit {updated_commit[:7]}' if updated_branch: message += f' on branch {updated_branch}' elif updated_version and updated_version != current_version: message = f'Plugin {plugin_id} updated to version {updated_version}' elif updated_last_updated and updated_last_updated != current_last_updated: message = f'Plugin {plugin_id} refreshed (Last Updated {updated_last_updated})' elif not current_commit: update_status = 'up_to_date' message = f'Plugin {plugin_id} already up to date' if updated_version: message += f' (version {updated_version})' remote_commit_short = remote_commit[:7] if remote_commit else None if remote_commit_short and updated_commit and remote_commit_short != updated_commit[:7]: message += f' (remote latest {remote_commit_short})' # Invalidate schema cache if api_v3.schema_manager: api_v3.schema_manager.invalidate_cache(plugin_id) # Rediscover plugins if api_v3.plugin_manager: api_v3.plugin_manager.discover_plugins() if plugin_id in api_v3.plugin_manager.plugins: api_v3.plugin_manager.reload_plugin(plugin_id) # Update state and history if api_v3.plugin_state_manager: api_v3.plugin_state_manager.update_plugin_state( plugin_id, {'last_updated': datetime.now()} ) if api_v3.operation_history: version = _get_plugin_version(plugin_id) api_v3.operation_history.record_operation( "update", plugin_id=plugin_id, status="success", details={ "version": version, "previous_commit": current_commit[:7] if current_commit else None, "commit": updated_commit[:7] if updated_commit else None, "branch": updated_branch, "update_status": update_status } ) return success_response( data={ 'last_updated': updated_last_updated, 'commit': updated_commit, 'update_status': update_status }, message=message ) else: if plugin_dir is None or not plugin_dir.exists(): client_msg = 'Plugin update failed: plugin not found' else: git_info = api_v3.plugin_store_manager._get_local_git_info(plugin_dir) if not git_info: plugin_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id) if not plugin_info: client_msg = 'Plugin update failed: not found in registry' else: client_msg = 'Plugin update failed; check logs for details' else: client_msg = 'Plugin update failed; check logs for details' logger.error("update_plugin failed for plugin_id=%s: %s", plugin_id, client_msg) if api_v3.operation_history: api_v3.operation_history.record_operation( "update", plugin_id=plugin_id, status="failed", error=client_msg, details={ "previous_commit": current_commit[:7] if current_commit else None, "branch": current_branch } ) return error_response( ErrorCode.PLUGIN_UPDATE_FAILED, client_msg, status_code=500 ) except Exception as e: logger.error("Unhandled exception in update endpoint", exc_info=True) if api_v3.operation_history: api_v3.operation_history.record_operation( "update", plugin_id=data.get('plugin_id') if 'data' in locals() else None, status="failed", error=str(e) ) return exception_error_response(e, ErrorCode.PLUGIN_UPDATE_FAILED) @api_v3.route('/plugins/uninstall', methods=['POST']) def uninstall_plugin(): """Uninstall plugin""" try: # Validate request data, error = validate_request_json(['plugin_id']) if error: return error if not api_v3.plugin_store_manager: return error_response( ErrorCode.SYSTEM_ERROR, 'Plugin store manager not initialized', status_code=500 ) plugin_id = data['plugin_id'] preserve_config = data.get('preserve_config', False) id_error = _non_plugin_id_error(plugin_id) if id_error: return id_error # Both queued and direct paths use the same transactional helper so # snapshot/rollback behaviour is consistent regardless of deployment. if api_v3.operation_queue: def uninstall_callback(operation): """Callback to execute plugin uninstallation via transactional helper.""" success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config) if not success: if api_v3.operation_history: api_v3.operation_history.record_operation( "uninstall", plugin_id=plugin_id, status="failed", error=error_msg ) raise Exception(error_msg or f'Failed to uninstall plugin {plugin_id}') if api_v3.operation_history: api_v3.operation_history.record_operation( "uninstall", plugin_id=plugin_id, status="success", details={"preserve_config": preserve_config} ) return {'success': True, 'message': 'Plugin uninstalled successfully'} # Enqueue operation operation_id = api_v3.operation_queue.enqueue_operation( OperationType.UNINSTALL, plugin_id, operation_callback=uninstall_callback ) return success_response( data={'operation_id': operation_id}, message='Plugin uninstallation queued' ) else: # Direct (non-queued) transactional uninstall success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config) if success: if api_v3.operation_history: api_v3.operation_history.record_operation( "uninstall", plugin_id=plugin_id, status="success", details={"preserve_config": preserve_config} ) return success_response(message='Plugin uninstalled successfully') else: if api_v3.operation_history: api_v3.operation_history.record_operation( "uninstall", plugin_id=plugin_id, status="failed", error=error_msg ) return error_response( ErrorCode.PLUGIN_UNINSTALL_FAILED, error_msg or 'Plugin uninstall failed', status_code=500 ) except Exception as e: if api_v3.operation_history: api_v3.operation_history.record_operation( "uninstall", plugin_id=data.get('plugin_id') if 'data' in locals() else None, status="failed", error=str(e) ) return exception_error_response(e, ErrorCode.PLUGIN_UNINSTALL_FAILED) @api_v3.route('/plugins/install', methods=['POST']) def install_plugin(): """Install plugin from store""" if not api_v3.plugin_store_manager: return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 data = request.get_json(silent=True) if not data or 'plugin_id' not in data: return jsonify({'status': 'error', 'message': 'plugin_id required'}), 400 plugin_id = data['plugin_id'] branch = data.get('branch') # Optional branch parameter # A registry entry that isn't a plugin (a custom registry can still # list old "type": "skin" entries) gets a clear refusal, not a failed # install. try: registry_entry = api_v3.plugin_store_manager.get_registry_info(plugin_id) except Exception: registry_entry = None if isinstance(registry_entry, dict) and not api_v3.plugin_store_manager.is_plugin_entry(registry_entry): return jsonify({'status': 'error', 'message': f"{plugin_id} is a {registry_entry.get('type')!r} entry, not a plugin"}), 400 plugins_dir = api_v3.plugin_store_manager.plugins_dir logger.info("Installing plugin to directory: %s", plugins_dir) # Use operation queue if available if api_v3.operation_queue: def install_callback(operation): """Callback to execute plugin installation.""" success = api_v3.plugin_store_manager.install_plugin(plugin_id, branch=branch) if success: # Invalidate schema cache if api_v3.schema_manager: api_v3.schema_manager.invalidate_cache(plugin_id) # Discover and load the new plugin if api_v3.plugin_manager: api_v3.plugin_manager.discover_plugins() api_v3.plugin_manager.load_plugin(plugin_id) # Update state manager if api_v3.plugin_state_manager: api_v3.plugin_state_manager.set_plugin_installed(plugin_id) # Record in history if api_v3.operation_history: version = _get_plugin_version(plugin_id) api_v3.operation_history.record_operation( "install", plugin_id=plugin_id, status="success", details={"version": version, "branch": branch} ) branch_msg = f" (branch: {branch})" if branch else "" return {'success': True, 'message': f'Plugin {plugin_id} installed successfully{branch_msg}'} else: error_msg = f'Failed to install plugin {plugin_id}' if branch: error_msg += f' (branch: {branch})' plugin_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id) if not plugin_info: error_msg += ' (plugin not found in registry)' # Record failure in history if api_v3.operation_history: api_v3.operation_history.record_operation( "install", plugin_id=plugin_id, status="failed", error=error_msg, details={"branch": branch} ) raise Exception(error_msg) # Enqueue operation operation_id = api_v3.operation_queue.enqueue_operation( OperationType.INSTALL, plugin_id, operation_callback=install_callback ) branch_msg = f" (branch: {branch})" if branch else "" return success_response( data={'operation_id': operation_id}, message=f'Plugin {plugin_id} installation queued{branch_msg}' ) else: # Fallback to direct installation success = api_v3.plugin_store_manager.install_plugin(plugin_id, branch=branch) if success: if api_v3.schema_manager: api_v3.schema_manager.invalidate_cache(plugin_id) if api_v3.plugin_manager: api_v3.plugin_manager.discover_plugins() api_v3.plugin_manager.load_plugin(plugin_id) if api_v3.plugin_state_manager: api_v3.plugin_state_manager.set_plugin_installed(plugin_id) if api_v3.operation_history: version = _get_plugin_version(plugin_id) api_v3.operation_history.record_operation( "install", plugin_id=plugin_id, status="success", details={"version": version, "branch": branch} ) branch_msg = f" (branch: {branch})" if branch else "" return success_response(message=f'Plugin installed successfully{branch_msg}') else: error_msg = f'Failed to install plugin {plugin_id}' if branch: error_msg += f' (branch: {branch})' plugin_info = api_v3.plugin_store_manager.get_plugin_info(plugin_id) if not plugin_info: error_msg += ' (plugin not found in registry)' if api_v3.operation_history: api_v3.operation_history.record_operation( "install", plugin_id=plugin_id, status="failed", error=error_msg, details={"branch": branch} ) return error_response( ErrorCode.PLUGIN_INSTALL_FAILED, error_msg, status_code=500 ) @api_v3.route('/plugins/install-from-url', methods=['POST']) def install_plugin_from_url(): """Install plugin from custom GitHub URL""" if not api_v3.plugin_store_manager: return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 data = request.get_json(silent=True) if not data or 'repo_url' not in data: return jsonify({'status': 'error', 'message': 'repo_url required'}), 400 # A non-string repo_url is a client mistake, not a server fault: # .strip() would raise and the catch-all would report it as a 500. if not isinstance(data['repo_url'], str) or not data['repo_url'].strip(): return jsonify({'status': 'error', 'message': 'repo_url must be a non-empty string'}), 400 repo_url = data['repo_url'].strip() plugin_id = data.get('plugin_id') # Optional, for monorepo installations plugin_path = data.get('plugin_path') # Optional, for monorepo subdirectory branch = data.get('branch') # Optional branch parameter # Install the plugin result = api_v3.plugin_store_manager.install_from_url( repo_url=repo_url, plugin_id=plugin_id, plugin_path=plugin_path, branch=branch ) if result.get('success'): # Invalidate schema cache for the installed plugin installed_plugin_id = result.get('plugin_id') if api_v3.schema_manager and installed_plugin_id: api_v3.schema_manager.invalidate_cache(installed_plugin_id) # Discover and load the new plugin if api_v3.plugin_manager and installed_plugin_id: api_v3.plugin_manager.discover_plugins() api_v3.plugin_manager.load_plugin(installed_plugin_id) branch_msg = f" (branch: {result.get('branch', branch)})" if (result.get('branch') or branch) else "" response_data = { 'status': 'success', 'message': f"Plugin {installed_plugin_id} installed successfully{branch_msg}", 'plugin_id': installed_plugin_id, 'name': result.get('name') } if result.get('branch'): response_data['branch'] = result.get('branch') return jsonify(response_data) else: return jsonify({ 'status': 'error', 'message': result.get('error', 'Failed to install plugin from URL') }), 500 @api_v3.route('/plugins/registry-from-url', methods=['POST']) def get_registry_from_url(): """Get plugin list from a registry-style monorepo URL""" if not api_v3.plugin_store_manager: return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 data = request.get_json(silent=True) if not data or 'repo_url' not in data: return jsonify({'status': 'error', 'message': 'repo_url required'}), 400 # A non-string repo_url is a client mistake, not a server fault: # .strip() would raise and the catch-all would report it as a 500. if not isinstance(data['repo_url'], str) or not data['repo_url'].strip(): return jsonify({'status': 'error', 'message': 'repo_url must be a non-empty string'}), 400 repo_url = data['repo_url'].strip() # Get registry from the URL registry = api_v3.plugin_store_manager.fetch_registry_from_url(repo_url) if registry: return jsonify({ 'status': 'success', 'plugins': [p for p in registry.get('plugins', []) if api_v3.plugin_store_manager.is_plugin_entry(p)], 'registry_url': repo_url }) else: return jsonify({ 'status': 'error', 'message': 'Failed to fetch registry from URL or URL does not contain a valid registry' }), 400 @api_v3.route('/plugins/saved-repositories', methods=['GET']) def get_saved_repositories(): """Get all saved repositories""" if not api_v3.saved_repositories_manager: return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500 repositories = api_v3.saved_repositories_manager.get_all() return jsonify({'status': 'success', 'data': {'repositories': repositories}}) @api_v3.route('/plugins/saved-repositories', methods=['POST']) def add_saved_repository(): """Add a repository to saved list""" if not api_v3.saved_repositories_manager: return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500 data = request.get_json(silent=True) if not data or 'repo_url' not in data: return jsonify({'status': 'error', 'message': 'repo_url required'}), 400 # A non-string repo_url is a client mistake, not a server fault: # .strip() would raise and the catch-all would report it as a 500. if not isinstance(data['repo_url'], str) or not data['repo_url'].strip(): return jsonify({'status': 'error', 'message': 'repo_url must be a non-empty string'}), 400 repo_url = data['repo_url'].strip() name = data.get('name') success = api_v3.saved_repositories_manager.add(repo_url, name) if success: return jsonify({ 'status': 'success', 'message': 'Repository saved successfully', 'data': {'repositories': api_v3.saved_repositories_manager.get_all()} }) else: return jsonify({ 'status': 'error', 'message': 'Repository already exists or failed to save' }), 400 @api_v3.route('/plugins/saved-repositories', methods=['DELETE']) def remove_saved_repository(): """Remove a repository from saved list""" if not api_v3.saved_repositories_manager: return jsonify({'status': 'error', 'message': 'Saved repositories manager not initialized'}), 500 data = request.get_json(silent=True) if not data or 'repo_url' not in data: return jsonify({'status': 'error', 'message': 'repo_url required'}), 400 repo_url = data['repo_url'] success = api_v3.saved_repositories_manager.remove(repo_url) if success: return jsonify({ 'status': 'success', 'message': 'Repository removed successfully', 'data': {'repositories': api_v3.saved_repositories_manager.get_all()} }) else: return jsonify({ 'status': 'error', 'message': 'Repository not found' }), 404 @api_v3.route('/plugins/store/list', methods=['GET']) def list_plugin_store(): """Search plugin store""" if not api_v3.plugin_store_manager: return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 query = request.args.get('query', '') category = request.args.get('category', '') tags = request.args.getlist('tags') # Default to fetching commit metadata to ensure accurate commit timestamps fetch_commit_param = request.args.get('fetch_commit_info', request.args.get('fetch_latest_versions', '')).lower() fetch_commit = fetch_commit_param != 'false' # Search plugins from the registry (including saved repositories) plugins = api_v3.plugin_store_manager.search_plugins( query=query, category=category, tags=tags, fetch_commit_info=fetch_commit, include_saved_repos=True, saved_repositories_manager=api_v3.saved_repositories_manager ) # Format plugins for the web interface formatted_plugins = [] for plugin in plugins: if not api_v3.plugin_store_manager.is_plugin_entry(plugin): continue formatted_plugins.append({ 'id': plugin.get('id'), 'name': plugin.get('name'), 'author': plugin.get('author'), 'category': plugin.get('category'), 'description': plugin.get('description'), 'tags': plugin.get('tags', []), 'stars': plugin.get('stars', 0), 'verified': plugin.get('verified', False), 'repo': plugin.get('repo', ''), 'last_updated': plugin.get('last_updated') or plugin.get('last_updated_iso', ''), 'last_updated_iso': plugin.get('last_updated_iso', ''), 'last_commit': plugin.get('last_commit') or plugin.get('last_commit_sha'), 'last_commit_message': plugin.get('last_commit_message'), 'last_commit_author': plugin.get('last_commit_author'), 'version': plugin.get('latest_version') or plugin.get('version', ''), 'branch': plugin.get('branch') or plugin.get('default_branch'), 'default_branch': plugin.get('default_branch'), 'plugin_path': plugin.get('plugin_path', '') }) return jsonify({'status': 'success', 'data': {'plugins': formatted_plugins}}) @api_v3.route('/plugins/store/github-status', methods=['GET']) def get_github_auth_status(): """Check if GitHub authentication is configured and validate token""" if not api_v3.plugin_store_manager: return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 token = api_v3.plugin_store_manager.github_token # Check if GitHub token is configured if not token or len(token) == 0: return jsonify({ 'status': 'success', 'data': { 'token_status': 'none', 'authenticated': False, 'rate_limit': 60, 'message': 'No GitHub token configured', 'error': None } }) # Validate the token is_valid, error_message = api_v3.plugin_store_manager._validate_github_token(token) if is_valid: return jsonify({ 'status': 'success', 'data': { 'token_status': 'valid', 'authenticated': True, 'rate_limit': 5000, 'message': 'GitHub API authenticated', 'error': None } }) else: return jsonify({ 'status': 'success', 'data': { 'token_status': 'invalid', 'authenticated': False, 'rate_limit': 60, 'message': f'GitHub token is invalid: {error_message}' if error_message else 'GitHub token is invalid', 'error': error_message } }) @api_v3.route('/plugins/store/refresh', methods=['POST']) def refresh_plugin_store(): """Re-download the plugin registry, bypassing its cache. Takes no body. Answers ``{status, message, plugin_count}``, the count being the registry's entries. Commit metadata is not refreshed here: the store list fetches it per plugin when it is shown. """ if not api_v3.plugin_store_manager: return jsonify({'status': 'error', 'message': 'Plugin store manager not initialized'}), 500 registry = api_v3.plugin_store_manager.fetch_registry(force_refresh=True) plugin_count = len(registry.get('plugins', [])) return jsonify({ 'status': 'success', 'message': 'Plugin store refreshed', 'plugin_count': plugin_count })