""" GitHub Installer - PyPlanet app for installing plugins from GitHub. Commands: /ghinstall / Install a plugin from GitHub (zip from main/master branch) /ghinstall Install from a direct zip URL /ghlist List installed GitHub plugins /ghremove Remove a GitHub-installed plugin /ghupdate Update (re-download) a GitHub plugin """ import os import io import json import shutil import logging import zipfile import re import aiohttp from pyplanet.apps.config import AppConfig from pyplanet.contrib.command import Command from pyplanet.contrib.setting import Setting class GitHubInstaller(AppConfig): name = 'github_installer' app_dependencies = [] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.logger = logging.getLogger(__name__) self.installed_marker = '.github_installed' self.manifest_file = 'github_plugins.json' # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------ async def on_start(self): # Register commands. await self.instance.command_manager.register( Command(command='ghinstall', target=self.cmd_install, description='Install a plugin from GitHub').add_param( 'source', required=True, help='GitHub user/repo or direct zip URL'), Command(command='ghlist', target=self.cmd_list, description='List GitHub-installed plugins'), Command(command='ghremove', target=self.cmd_remove, description='Remove a GitHub-installed plugin').add_param( 'name', required=True, help='Plugin folder name'), Command(command='ghupdate', target=self.cmd_update, description='Update a GitHub-installed plugin').add_param( 'name', required=True, help='Plugin folder name'), ) self.logger.info('GitHub Installer started.') # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ def _apps_dir(self): """Return the absolute path to the apps/ directory.""" return os.path.join(os.getcwd(), 'apps') def _manifest_path(self): return os.path.join(self._apps_dir(), self.manifest_file) def _load_manifest(self): if os.path.isfile(self._manifest_path()): with open(self._manifest_path(), 'r', encoding='utf-8') as f: return json.load(f) return {} def _save_manifest(self, data): with open(self._manifest_path(), 'w', encoding='utf-8') as f: json.dump(data, f, indent=2, ensure_ascii=False) @staticmethod def _parse_github_repo(source): """Return (owner, repo) if source looks like 'owner/repo', else None.""" m = re.match(r'^([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)$', source.strip()) if m: return m.group(1), m.group(2) return None @staticmethod def _is_zip_url(source): return source.strip().lower().endswith('.zip') and source.strip().startswith('http') async def _download_zip(self, url): """Download a zip file from *url* and return the bytes.""" self.logger.info('Downloading %s ...', url) async with aiohttp.ClientSession() as session: async with session.get(url) as resp: if resp.status != 200: raise RuntimeError(f'Download failed: HTTP {resp.status} for {url}') return await resp.read() def _extract_plugin(self, zip_bytes, plugin_name): """ Extract zip bytes into apps//. Returns the path to the extracted folder. """ apps_dir = self._apps_dir() dest = os.path.join(apps_dir, plugin_name) # Remove old installation if present. if os.path.isdir(dest): shutil.rmtree(dest) with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: # Determine the top-level folder name inside the zip. # GitHub archives look like: repo-name-main/ or repo-name-master/ top_dirs = set() for info in zf.infolist(): parts = info.filename.split('/') if len(parts) > 1: top_dirs.add(parts[0]) # If there's exactly one top-level dir, strip it. if len(top_dirs) == 1: top = top_dirs.pop() prefix = top + '/' for info in zf.infolist(): if info.filename.startswith(prefix): info.filename = info.filename[len(prefix):] elif info.filename == top + '/': continue if info.filename: # skip empty zf.extract(info, dest) else: zf.extractall(dest) # Write marker file. marker_path = os.path.join(dest, self.installed_marker) with open(marker_path, 'w') as f: f.write(plugin_name) return dest # ------------------------------------------------------------------ # Commands # ------------------------------------------------------------------ async def cmd_install(self, player, data, **kwargs): source = data.source.strip() # Determine download URL and plugin name. parsed = self._parse_github_repo(source) if parsed: owner, repo = parsed # Try main first, then master. urls = [ f'https://github.com/{owner}/{repo}/archive/refs/heads/main.zip', f'https://github.com/{owner}/{repo}/archive/refs/heads/master.zip', ] plugin_name = repo elif self._is_zip_url(source): # Direct zip URL — derive name from URL. plugin_name = source.split('/')[-1].replace('.zip', '') urls = [source] else: await self.instance.chat( 'Usage: /ghinstall owner/repo OR /ghinstall https://.../something.zip', player) return # Download. zip_bytes = None last_err = None for url in urls: try: zip_bytes = await self._download_zip(url) break except Exception as e: last_err = e continue if zip_bytes is None: err_msg = str(last_err) if last_err else 'Unknown error' await self.instance.chat(f'Failed to download plugin: {err_msg}', player) return # Extract. try: dest = self._extract_plugin(zip_bytes, plugin_name) except Exception as e: self.logger.exception('Failed to extract plugin') await self.instance.chat(f'Failed to extract plugin: {e}', player) return # Update manifest. manifest = self._load_manifest() manifest[plugin_name] = { 'source': source, 'path': dest, 'installed_by': player.login, } self._save_manifest(manifest) await self.instance.chat( f'Plugin "$fff{plugin_name}$fff" installed to apps/{plugin_name}/. ' f'Restart PyPlanet or use /ghreload to activate it.', player, ) self.logger.info('Plugin %s installed by %s from %s', plugin_name, player.login, source) async def cmd_list(self, player, data=None, **kwargs): manifest = self._load_manifest() if not manifest: await self.instance.chat('No GitHub-installed plugins found.', player) return lines = ['GitHub-installed plugins:'] for name, info in manifest.items(): lines.append(f' $fff{name}$fff (from {info.get("source", "?")})') await self.instance.chat('\n'.join(lines), player) async def cmd_remove(self, player, data, **kwargs): name = data.name.strip() manifest = self._load_manifest() if name not in manifest: await self.instance.chat(f'Plugin "$fff{name}$fff" not found in manifest.', player) return # Remove directory. dest = os.path.join(self._apps_dir(), name) if os.path.isdir(dest): shutil.rmtree(dest) del manifest[name] self._save_manifest(manifest) await self.instance.chat( f'Plugin "$fff{name}$fff" removed. Restart PyPlanet to fully deactivate.', player, ) self.logger.info('Plugin %s removed by %s', name, player.login) async def cmd_update(self, player, data, **kwargs): name = data.name.strip() manifest = self._load_manifest() if name not in manifest: await self.instance.chat(f'Plugin "$fff{name}$fff" not found. Use /ghinstall to install.', player) return source = manifest[name].get('source', '') if not source: await self.instance.chat(f'No source recorded for "$fff{name}$fff". Reinstall with /ghinstall.', player) return # Re-run install (overwrites). # Build a fake data object for cmd_install. from types import SimpleNamespace fake_data = SimpleNamespace(source=source) await self.cmd_install(player, fake_data)