feat: add plugin installer with support for ZIP and Git installations

- Introduced a new Plugin Installer feature allowing users to install plugins from ZIP files, Git repositories, or a community index. - Added API endpoints for handling plugin installations and fetching the plugin index. - Created web UI components for browsing plugins, installing from ZIP or Git, and displaying plugin details. - Implemented necessary helper functions for validating and managing plugin installations.

TerminallyLazy committed Mar 3, 2026 at 10:53 UTC 99e6c2b41908b4115916e630fde2a118fdd24aff
10 files changed +1452
plugins/plugin_installer/api/plugin_install.py new
+73
@@ -0,0 +1,73 @@
1 +from __future__ import annotations
2 +
3 +import time
4 +import uuid
5 +from pathlib import Path
6 +
7 +from python.helpers.api import ApiHandler, Input, Request, Output
8 +from python.helpers import files
9 +from werkzeug.datastructures import FileStorage
10 +from werkzeug.utils import secure_filename
11 +
12 +
13 +class PluginInstall(ApiHandler):
14 + """Plugin installation API. Handles ZIP upload, Git clone, and index fetch."""
15 +
16 + async def process(self, input: Input, request: Request) -> Output:
17 + action = input.get("action", "") or request.form.get("action", "")
18 +
19 + try:
20 + if action == "install_zip":
21 + return self._install_zip(request)
22 + elif action == "install_git":
23 + return self._install_git(input)
24 + elif action == "fetch_index":
25 + return self._fetch_index(input)
26 + else:
27 + return {"success": False, "error": f"Unknown action: {action}"}
28 + except ValueError as e:
29 + return {"success": False, "error": str(e)}
30 + except Exception as e:
31 + return {"success": False, "error": f"Installation failed: {e}"}
32 +
33 + def _install_zip(self, request: Request) -> dict:
34 + if "plugin_file" not in request.files:
35 + return {"success": False, "error": "No file provided"}
36 +
37 + plugin_file: FileStorage = request.files["plugin_file"]
38 + if not plugin_file.filename:
39 + return {"success": False, "error": "No file selected"}
40 +
41 + # Save upload to temp
42 + tmp_dir = Path(files.get_abs_path("tmp", "plugin_uploads"))
43 + tmp_dir.mkdir(parents=True, exist_ok=True)
44 + base = secure_filename(plugin_file.filename)
45 + if not base.lower().endswith(".zip"):
46 + base = f"{base}.zip"
47 + unique = uuid.uuid4().hex[:8]
48 + stamp = time.strftime("%Y%m%d_%H%M%S")
49 + tmp_path = str(tmp_dir / f"plugin_{stamp}_{unique}_{base}")
50 + plugin_file.save(tmp_path)
51 +
52 + from plugins.plugin_installer.helpers.install import install_from_zip
53 + return install_from_zip(tmp_path)
54 +
55 + def _install_git(self, input: dict) -> dict:
56 + git_url = (input.get("git_url", "") or "").strip()
57 + git_token = (input.get("git_token", "") or "").strip() or None
58 + if not git_url:
59 + return {"success": False, "error": "Git URL is required"}
60 +
61 + from plugins.plugin_installer.helpers.install import install_from_git
62 + return install_from_git(git_url, git_token)
63 +
64 + def _fetch_index(self, input: dict) -> dict:
65 + from plugins.plugin_installer.helpers.install import fetch_plugin_index
66 + from python.helpers.plugins import get_plugins_list
67 + index_data = fetch_plugin_index()
68 + installed = get_plugins_list()
69 + return {
70 + "success": True,
71 + "index": index_data,
72 + "installed_plugins": installed,
73 + }
plugins/plugin_installer/extensions/webui/plugins-list-header-buttons/install-buttons.html new
+136
@@ -0,0 +1,136 @@
1 +<span x-data="{ open: false }">
2 + <script type="module">
3 + import { store } from "/plugins/plugin_installer/webui/pluginInstallStore.js";
4 + </script>
5 +
6 + <div class="pi-install-wrapper" @click.outside="open = false">
7 + <button type="button"
8 + class="button"
9 + @click="open = !open">
10 + <span class="icon material-symbols-outlined">add</span> Install
11 + <span class="material-symbols-outlined pi-chevron"
12 + :class="{ 'pi-chevron-open': open }">expand_more</span>
13 + </button>
14 +
15 + <div class="pi-dropdown" x-show="open" x-cloak x-transition.opacity.duration.150ms>
16 + <button type="button" class="pi-dropdown-item"
17 + @click="open = false; openModal('../plugins/plugin_installer/webui/install-index.html')">
18 + <span class="material-symbols-outlined">store</span>
19 + <span class="pi-item-text">
20 + <span class="pi-item-title">Browse Plugins</span>
21 + <span class="pi-item-desc">Explore the community index</span>
22 + </span>
23 + </button>
24 + <div class="pi-dropdown-sep"></div>
25 + <button type="button" class="pi-dropdown-item"
26 + @click="open = false; openModal('../plugins/plugin_installer/webui/install-git.html')">
27 + <span class="material-symbols-outlined">terminal</span>
28 + <span class="pi-item-text">
29 + <span class="pi-item-title">Clone from Git</span>
30 + <span class="pi-item-desc">Install from a repository URL</span>
31 + </span>
32 + </button>
33 + <button type="button" class="pi-dropdown-item"
34 + @click="open = false; openModal('../plugins/plugin_installer/webui/install-zip.html')">
35 + <span class="material-symbols-outlined">upload_file</span>
36 + <span class="pi-item-text">
37 + <span class="pi-item-title">Upload ZIP</span>
38 + <span class="pi-item-desc">Install from a local archive</span>
39 + </span>
40 + </button>
41 + </div>
42 + </div>
43 +
44 + <style>
45 + .plugins-toolbar-actions {
46 + display: flex;
47 + align-items: center;
48 + gap: 0.4rem;
49 + }
50 +
51 + .pi-install-wrapper {
52 + position: relative;
53 + }
54 +
55 + .pi-install-wrapper > .button {
56 + gap: 0.25rem;
57 + }
58 +
59 + .pi-chevron {
60 + font-size: 1.1rem !important;
61 + transition: transform 0.2s ease;
62 + }
63 +
64 + .pi-chevron-open {
65 + transform: rotate(180deg);
66 + }
67 +
68 + .pi-dropdown {
69 + position: absolute;
70 + top: calc(100% + 0.35rem);
71 + right: 0;
72 + min-width: 230px;
73 + background: var(--color-panel);
74 + border: 1px solid var(--color-border);
75 + border-radius: 8px;
76 + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
77 + z-index: 100;
78 + padding: 0.3rem;
79 + }
80 +
81 + .pi-dropdown-item {
82 + display: flex;
83 + align-items: center;
84 + gap: 0.6rem;
85 + width: 100%;
86 + padding: 0.5rem 0.6rem;
87 + background: none;
88 + border: none;
89 + border-radius: 5px;
90 + color: var(--color-text);
91 + cursor: pointer;
92 + text-align: left;
93 + font-family: inherit;
94 + font-size: 0.875rem;
95 + transition: background 0.12s ease;
96 + }
97 +
98 + .pi-dropdown-item:hover {
99 + background: var(--color-background);
100 + }
101 +
102 + .pi-dropdown-item .material-symbols-outlined {
103 + font-size: 1.25rem;
104 + color: var(--color-text-secondary);
105 + flex-shrink: 0;
106 + }
107 +
108 + .pi-dropdown-item:hover .material-symbols-outlined {
109 + color: var(--color-highlight);
110 + }
111 +
112 + .pi-item-text {
113 + display: flex;
114 + flex-direction: column;
115 + }
116 +
117 + .pi-item-title {
118 + font-weight: 500;
119 + line-height: 1.3;
120 + }
121 +
122 + .pi-item-desc {
123 + font-size: 0.75rem;
124 + color: var(--color-text-secondary);
125 + line-height: 1.3;
126 + }
127 +
128 + .pi-dropdown-sep {
129 + height: 1px;
130 + background: var(--color-border);
131 + margin: 0.2rem 0.5rem;
132 + }
133 +
134 + [x-cloak] { display: none !important; }
135 + </style>
136 +</span>
plugins/plugin_installer/helpers/__init__.py
plugins/plugin_installer/helpers/install.py new
+176
@@ -0,0 +1,176 @@
1 +from __future__ import annotations
2 +
3 +import os
4 +import re
5 +import shutil
6 +import time
7 +import zipfile
8 +from pathlib import Path
9 +from typing import Optional
10 +
11 +from python.helpers import files
12 +from python.helpers.plugins import (
13 + META_FILE_NAME,
14 + PluginMetadata,
15 + invalidate_plugin_cache,
16 +)
17 +from python.helpers import yaml as yaml_helper
18 +
19 +_SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]*$")
20 +
21 +
22 +def _get_user_plugins_dir() -> str:
23 + """Return absolute path to usr/plugins/."""
24 + return files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR)
25 +
26 +
27 +def _sanitize_plugin_name(name: str) -> str:
28 + """Validate and sanitize a plugin directory name.
29 + Raises ValueError if the name is unsafe for filesystem use."""
30 + name = name.strip().strip(".")
31 + if not name or not _SAFE_NAME_RE.match(name):
32 + raise ValueError(
33 + f"Invalid plugin name: '{name}'. "
34 + "Names must start with a letter or digit and contain only letters, digits, hyphens, underscores, or dots."
35 + )
36 + return name
37 +
38 +
39 +def validate_plugin_dir(path: str) -> PluginMetadata:
40 + """Check directory contains plugin.yaml and return parsed metadata.
41 + Raises ValueError if plugin.yaml is missing or invalid."""
42 + meta_path = os.path.join(path, META_FILE_NAME)
43 + if not os.path.isfile(meta_path):
44 + raise ValueError(f"No {META_FILE_NAME} found in {os.path.basename(path)}")
45 + with open(meta_path, "r", encoding="utf-8") as f:
46 + content = f.read()
47 + data = yaml_helper.loads(content)
48 + return PluginMetadata.model_validate(data)
49 +
50 +
51 +def check_plugin_conflict(name: str) -> None:
52 + """Raise ValueError if a plugin with this name already exists in usr/plugins/."""
53 + dest = os.path.join(_get_user_plugins_dir(), name)
54 + if os.path.exists(dest):
55 + raise ValueError(f"Plugin '{name}' is already installed")
56 +
57 +
58 +def _find_plugin_root(extracted_dir: str) -> str:
59 + """Walk extracted directory to find the parent of plugin.yaml.
60 + Returns absolute path to the plugin root directory."""
61 + for root, dirs, dir_files in os.walk(extracted_dir):
62 + if META_FILE_NAME in dir_files:
63 + return root
64 + raise ValueError(f"No {META_FILE_NAME} found in the uploaded archive")
65 +
66 +
67 +def install_from_zip(zip_path: str) -> dict:
68 + """Extract ZIP, find plugin.yaml, move its parent to usr/plugins/.
69 + Returns dict with plugin name and metadata.
70 + Cleans up tmp files regardless of outcome."""
71 + base_tmp = files.get_abs_path("tmp", "plugin_installs")
72 + os.makedirs(base_tmp, exist_ok=True)
73 + stamp = time.strftime("%Y%m%d_%H%M%S")
74 + extract_dir = os.path.join(base_tmp, f"extract_{stamp}")
75 + os.makedirs(extract_dir, exist_ok=True)
76 +
77 + try:
78 + # Extract with path traversal protection
79 + try:
80 + with zipfile.ZipFile(zip_path, "r") as z:
81 + real_extract = os.path.realpath(extract_dir)
82 + for member in z.namelist():
83 + member_path = os.path.realpath(os.path.join(extract_dir, member))
84 + if not member_path.startswith(real_extract + os.sep) and member_path != real_extract:
85 + raise ValueError(f"Unsafe path in archive: {member}")
86 + z.extractall(extract_dir)
87 + except zipfile.BadZipFile:
88 + raise ValueError("The uploaded file is not a valid ZIP archive")
89 +
90 + # Find plugin.yaml
91 + plugin_root = _find_plugin_root(extract_dir)
92 + meta = validate_plugin_dir(plugin_root)
93 + plugin_name = os.path.basename(plugin_root)
94 +
95 + # If the zip only has one top-level dir that IS the plugin root,
96 + # use that dir's name. Otherwise use the zip's stem.
97 + if plugin_root == extract_dir:
98 + # plugin.yaml at root of extraction — use zip filename stem
99 + plugin_name = Path(zip_path).stem
100 +
101 + plugin_name = _sanitize_plugin_name(plugin_name)
102 + check_plugin_conflict(plugin_name)
103 +
104 + # Move to usr/plugins/
105 + dest = os.path.join(_get_user_plugins_dir(), plugin_name)
106 + os.makedirs(os.path.dirname(dest), exist_ok=True)
107 + shutil.move(plugin_root, dest)
108 + invalidate_plugin_cache()
109 +
110 + return {
111 + "success": True,
112 + "plugin_name": plugin_name,
113 + "title": meta.title or plugin_name,
114 + "path": files.deabsolute_path(dest),
115 + }
116 + finally:
117 + # Cleanup: extracted files and the archive
118 + shutil.rmtree(extract_dir, ignore_errors=True)
119 + try:
120 + os.unlink(zip_path)
121 + except OSError:
122 + pass
123 +
124 +
125 +def install_from_git(url: str, token: Optional[str] = None) -> dict:
126 + """Clone git repo into usr/plugins/, validate plugin.yaml.
127 + Returns dict with plugin name and metadata."""
128 + from python.helpers.git import clone_repo
129 +
130 + # Derive plugin name from URL
131 + repo_name = url.rstrip("/").split("/")[-1]
132 + if repo_name.endswith(".git"):
133 + repo_name = repo_name[:-4]
134 + if not repo_name:
135 + raise ValueError("Could not derive plugin name from URL")
136 +
137 + repo_name = _sanitize_plugin_name(repo_name)
138 + check_plugin_conflict(repo_name)
139 +
140 + dest = os.path.join(_get_user_plugins_dir(), repo_name)
141 + os.makedirs(os.path.dirname(dest), exist_ok=True)
142 +
143 + try:
144 + clone_repo(url, dest, token=token or None)
145 + except Exception as e:
146 + # Cleanup partial clone
147 + shutil.rmtree(dest, ignore_errors=True)
148 + raise ValueError(f"Git clone failed: {e}") from e
149 +
150 + try:
151 + meta = validate_plugin_dir(dest)
152 + except ValueError:
153 + # No plugin.yaml — remove cloned repo
154 + shutil.rmtree(dest, ignore_errors=True)
155 + raise
156 +
157 + invalidate_plugin_cache()
158 +
159 + return {
160 + "success": True,
161 + "plugin_name": repo_name,
162 + "title": meta.title or repo_name,
163 + "path": files.deabsolute_path(dest),
164 + }
165 +
166 +
167 +def fetch_plugin_index() -> dict:
168 + """Download the plugin index from GitHub releases."""
169 + import urllib.request
170 + import json
171 +
172 + index_url = "https://github.com/agent0ai/a0-plugins/releases/download/generated-index/index.json"
173 + req = urllib.request.Request(index_url, headers={"User-Agent": "AgentZero"})
174 + with urllib.request.urlopen(req, timeout=30) as resp:
175 + data = json.loads(resp.read().decode())
176 + return data
plugins/plugin_installer/plugin.yaml new
+5
@@ -0,0 +1,5 @@
1 +title: Plugin Installer
2 +description: Install plugins from ZIP files, Git repositories, or the community index.
3 +version: 1.0.0
4 +settings_sections: []
5 +always_enabled: true
plugins/plugin_installer/webui/install-detail.html new
+232
@@ -0,0 +1,232 @@
1 +<html>
2 +<head>
3 + <title>Plugin Details</title>
4 + <script type="module">
5 + import { store } from "/plugins/plugin_installer/webui/pluginInstallStore.js";
6 + </script>
7 +</head>
8 +<body>
9 + <div x-data>
10 + <template x-if="$store.pluginInstallStore?.selectedPlugin">
11 + <div>
12 + <div class="pi-detail-header">
13 + <div class="pi-detail-thumb">
14 + <template x-if="$store.pluginInstallStore.selectedPlugin.thumbnail">
15 + <img :src="$store.pluginInstallStore.selectedPlugin.thumbnail"
16 + :alt="$store.pluginInstallStore.selectedPlugin.title">
17 + </template>
18 + <template x-if="!$store.pluginInstallStore.selectedPlugin.thumbnail">
19 + <span class="material-symbols-outlined pi-detail-placeholder">extension</span>
20 + </template>
21 + </div>
22 + <div class="pi-detail-info">
23 + <h3 class="pi-detail-title" x-text="$store.pluginInstallStore.selectedPlugin.title || $store.pluginInstallStore.selectedPlugin.key"></h3>
24 + <div class="pi-detail-stars">
25 + <span class="material-symbols-outlined" style="font-size:1rem">star</span>
26 + <span x-text="($store.pluginInstallStore.selectedPlugin.stars || 0) + ' stars'"></span>
27 + </div>
28 + <div class="pi-detail-tags" x-show="$store.pluginInstallStore.selectedPlugin.tags?.length">
29 + <template x-for="tag in $store.pluginInstallStore.selectedPlugin.tags || []" :key="tag">
30 + <span class="pi-tag" x-text="tag"></span>
31 + </template>
32 + </div>
33 + </div>
34 + </div>
35 +
36 + <div class="pi-detail-desc" x-text="$store.pluginInstallStore.selectedPlugin.description || 'No description available.'"></div>
37 +
38 + <div class="pi-detail-links">
39 + <template x-if="$store.pluginInstallStore.selectedPlugin.github">
40 + <a :href="$store.pluginInstallStore.selectedPlugin.github" target="_blank" class="pi-link">
41 + <span class="material-symbols-outlined">code</span> GitHub Repository
42 + </a>
43 + </template>
44 + <template x-if="$store.pluginInstallStore.selectedPlugin.discussion">
45 + <a :href="$store.pluginInstallStore.selectedPlugin.discussion" target="_blank" class="pi-link">
46 + <span class="material-symbols-outlined">forum</span> Discussion
47 + </a>
48 + </template>
49 + </div>
50 +
51 + <div class="pi-detail-actions">
52 + <template x-if="$store.pluginInstallStore.selectedPlugin.installed">
53 + <button class="button" disabled>
54 + <span class="icon material-symbols-outlined">check</span> Already Installed
55 + </button>
56 + </template>
57 + <template x-if="!$store.pluginInstallStore.selectedPlugin.installed">
58 + <button class="button confirm"
59 + @click="$store.pluginInstallStore.installFromIndex($store.pluginInstallStore.selectedPlugin)"
60 + :disabled="$store.pluginInstallStore.loading">
61 + <template x-if="$store.pluginInstallStore.loading">
62 + <span class="pi-button-loading">
63 + <span class="spinner"></span>
64 + <span x-text="$store.pluginInstallStore.loadingMessage || 'Installing...'"></span>
65 + </span>
66 + </template>
67 + <template x-if="!$store.pluginInstallStore.loading">
68 + <span>
69 + <span class="icon material-symbols-outlined">download</span> Install Plugin
70 + </span>
71 + </template>
72 + </button>
73 + </template>
74 + </div>
75 +
76 + <div x-show="$store.pluginInstallStore.error" class="pi-error">
77 + <span x-text="$store.pluginInstallStore.error"></span>
78 + </div>
79 +
80 + <div x-show="$store.pluginInstallStore.result" class="pi-result">
81 + <div class="pi-result-icon">
82 + <span class="material-symbols-outlined">check_circle</span>
83 + </div>
84 + <div class="pi-result-text">
85 + <strong>Plugin installed successfully!</strong>
86 + <div class="pi-result-path" x-text="$store.pluginInstallStore.result?.path || ''"></div>
87 + </div>
88 + </div>
89 + </div>
90 + </template>
91 + </div>
92 +
93 + <style>
94 + .pi-detail-header {
95 + display: flex;
96 + gap: 1rem;
97 + margin-bottom: 1rem;
98 + }
99 +
100 + .pi-detail-thumb {
101 + width: 100px;
102 + height: 100px;
103 + border-radius: 8px;
104 + overflow: hidden;
105 + flex-shrink: 0;
106 + display: flex;
107 + align-items: center;
108 + justify-content: center;
109 + background: var(--color-panel);
110 + }
111 +
112 + .pi-detail-thumb img {
113 + width: 100%;
114 + height: 100%;
115 + object-fit: cover;
116 + }
117 +
118 + .pi-detail-placeholder {
119 + font-size: 3rem;
120 + color: var(--color-text-muted);
121 + }
122 +
123 + .pi-detail-info {
124 + flex: 1;
125 + }
126 +
127 + .pi-detail-title {
128 + margin: 0 0 0.3rem 0;
129 + font-size: 1.2rem;
130 + }
131 +
132 + .pi-detail-stars {
133 + display: inline-flex;
134 + align-items: center;
135 + gap: 0.25rem;
136 + font-size: 0.9rem;
137 + color: var(--color-text-secondary);
138 + margin-bottom: 0.4rem;
139 + }
140 +
141 + .pi-detail-tags {
142 + display: flex;
143 + flex-wrap: wrap;
144 + gap: 0.3rem;
145 + }
146 +
147 + .pi-tag {
148 + font-size: 0.75rem;
149 + padding: 0.15rem 0.5rem;
150 + border-radius: 3px;
151 + background: var(--color-panel);
152 + color: var(--color-text-secondary);
153 + border: 1px solid var(--color-border);
154 + }
155 +
156 + .pi-detail-desc {
157 + color: var(--color-text-primary);
158 + font-size: 0.95rem;
159 + line-height: 1.5;
160 + margin-bottom: 1rem;
161 + }
162 +
163 + .pi-detail-links {
164 + display: flex;
165 + gap: 1rem;
166 + margin-bottom: 1rem;
167 + }
168 +
169 + .pi-link {
170 + display: inline-flex;
171 + align-items: center;
172 + gap: 0.3rem;
173 + color: var(--color-highlight);
174 + text-decoration: none;
175 + font-size: 0.9rem;
176 + }
177 +
178 + .pi-link:hover {
179 + text-decoration: underline;
180 + }
181 +
182 + .pi-detail-actions {
183 + margin: 1rem 0;
184 + }
185 +
186 + .pi-button-loading {
187 + display: inline-flex;
188 + align-items: center;
189 + gap: 0.5rem;
190 + }
191 +
192 + .pi-error {
193 + color: var(--color-error);
194 + padding: 0.75rem;
195 + background: var(--color-error-bg, rgba(239,68,68,0.1));
196 + border-radius: 4px;
197 + margin-top: 0.75rem;
198 + }
199 +
200 + .pi-result {
201 + display: flex;
202 + align-items: center;
203 + gap: 0.75rem;
204 + padding: 0.75rem;
205 + background: rgba(34,197,94,0.1);
206 + border: 1px solid rgba(34,197,94,0.3);
207 + border-radius: 4px;
208 + margin-top: 0.75rem;
209 + }
210 +
211 + .pi-result-icon .material-symbols-outlined {
212 + font-size: 2rem;
213 + color: #22c55e;
214 + }
215 +
216 + .pi-result-path {
217 + font-size: 0.85rem;
218 + color: var(--color-text-secondary);
219 + margin-top: 0.25rem;
220 + font-family: var(--font-family-code);
221 + }
222 +
223 + @media (max-width: 500px) {
224 + .pi-detail-header {
225 + flex-direction: column;
226 + align-items: center;
227 + text-align: center;
228 + }
229 + }
230 + </style>
231 +</body>
232 +</html>
plugins/plugin_installer/webui/install-git.html new
+144
@@ -0,0 +1,144 @@
1 +<html>
2 +<head>
3 + <title>Install Plugin from Git</title>
4 + <script type="module">
5 + import { store } from "/plugins/plugin_installer/webui/pluginInstallStore.js";
6 + </script>
7 +</head>
8 +<body>
9 + <div x-data>
10 + <template x-if="$store.pluginInstallStore">
11 + <div x-init="$store.pluginInstallStore.resetGit()">
12 +
13 + <div class="pi-form-group">
14 + <label class="pi-label">Git Repository URL</label>
15 + <input type="text" class="pi-input"
16 + x-model="$store.pluginInstallStore.gitUrl"
17 + :disabled="$store.pluginInstallStore.loading"
18 + placeholder="https://github.com/user/my-plugin.git">
19 + </div>
20 +
21 + <div class="pi-form-group">
22 + <label class="pi-label">Access Token <span class="pi-optional">(optional, for private repos)</span></label>
23 + <input type="password" class="pi-input"
24 + x-model="$store.pluginInstallStore.gitToken"
25 + :disabled="$store.pluginInstallStore.loading"
26 + placeholder="ghp_xxxx or glpat-xxxx">
27 + <div class="pi-hint">Token is used only for cloning and is not stored.</div>
28 + </div>
29 +
30 + <div class="pi-actions">
31 + <button class="button confirm"
32 + @click="$store.pluginInstallStore.installGit()"
33 + :disabled="$store.pluginInstallStore.loading || !$store.pluginInstallStore.gitUrl.trim()">
34 + <template x-if="$store.pluginInstallStore.loading">
35 + <span class="pi-button-loading">
36 + <span class="spinner"></span>
37 + <span x-text="$store.pluginInstallStore.loadingMessage || 'Cloning...'"></span>
38 + </span>
39 + </template>
40 + <template x-if="!$store.pluginInstallStore.loading">
41 + <span>
42 + <span class="icon material-symbols-outlined">terminal</span> Clone & Install
43 + </span>
44 + </template>
45 + </button>
46 + </div>
47 +
48 + <div x-show="$store.pluginInstallStore.error" class="pi-error">
49 + <span x-text="$store.pluginInstallStore.error"></span>
50 + </div>
51 +
52 + <div x-show="$store.pluginInstallStore.result" class="pi-result">
53 + <div class="pi-result-icon">
54 + <span class="material-symbols-outlined">check_circle</span>
55 + </div>
56 + <div class="pi-result-text">
57 + <strong x-text="'Plugin installed: ' + ($store.pluginInstallStore.result?.title || $store.pluginInstallStore.result?.plugin_name || '')"></strong>
58 + <div class="pi-result-path" x-text="$store.pluginInstallStore.result?.path || ''"></div>
59 + </div>
60 + </div>
61 +
62 + </div>
63 + </template>
64 + </div>
65 +
66 + <style>
67 + .pi-form-group {
68 + margin-bottom: 1rem;
69 + }
70 +
71 + .pi-label {
72 + display: block;
73 + font-weight: 600;
74 + margin-bottom: 0.35rem;
75 + }
76 +
77 + .pi-optional {
78 + font-weight: 400;
79 + color: var(--color-text-secondary);
80 + font-size: 0.85rem;
81 + }
82 +
83 + .pi-input {
84 + width: 100%;
85 + padding: 0.6rem 0.75rem;
86 + border: 1px solid var(--color-border);
87 + border-radius: 4px;
88 + background: var(--color-bg-primary);
89 + color: var(--color-text-primary);
90 + font-size: 0.95rem;
91 + box-sizing: border-box;
92 + }
93 +
94 + .pi-hint {
95 + margin-top: 0.35rem;
96 + font-size: 0.8rem;
97 + color: var(--color-text-secondary);
98 + }
99 +
100 + .pi-actions {
101 + display: flex;
102 + justify-content: flex-start;
103 + margin: 1rem 0;
104 + }
105 +
106 + .pi-button-loading {
107 + display: inline-flex;
108 + align-items: center;
109 + gap: 0.5rem;
110 + }
111 +
112 + .pi-error {
113 + color: var(--color-error);
114 + padding: 0.75rem;
115 + background: var(--color-error-bg, rgba(239,68,68,0.1));
116 + border-radius: 4px;
117 + margin-top: 0.75rem;
118 + }
119 +
120 + .pi-result {
121 + display: flex;
122 + align-items: center;
123 + gap: 0.75rem;
124 + padding: 0.75rem;
125 + background: rgba(34,197,94,0.1);
126 + border: 1px solid rgba(34,197,94,0.3);
127 + border-radius: 4px;
128 + margin-top: 0.75rem;
129 + }
130 +
131 + .pi-result-icon .material-symbols-outlined {
132 + font-size: 2rem;
133 + color: #22c55e;
134 + }
135 +
136 + .pi-result-path {
137 + font-size: 0.85rem;
138 + color: var(--color-text-secondary);
139 + margin-top: 0.25rem;
140 + font-family: var(--font-family-code);
141 + }
142 + </style>
143 +</body>
144 +</html>
plugins/plugin_installer/webui/install-index.html new
+246
@@ -0,0 +1,246 @@
1 +<html>
2 +<head>
3 + <title>Browse Plugins</title>
4 + <script type="module">
5 + import { store } from "/plugins/plugin_installer/webui/pluginInstallStore.js";
6 + </script>
7 +</head>
8 +<body>
9 + <div x-data>
10 + <template x-if="$store.pluginInstallStore">
11 + <div x-init="$store.pluginInstallStore.resetIndex(); $store.pluginInstallStore.fetchIndex()">
12 +
13 + <!-- Search & Sort Bar -->
14 + <div class="pi-index-toolbar">
15 + <input type="text" class="pi-search-input"
16 + x-model="$store.pluginInstallStore.search"
17 + @input="$store.pluginInstallStore.page = 1"
18 + placeholder="Search plugins...">
19 + <select class="pi-sort-select"
20 + x-model="$store.pluginInstallStore.sortBy">
21 + <option value="stars">Sort by Stars</option>
22 + <option value="name">Sort by Name</option>
23 + </select>
24 + </div>
25 +
26 + <!-- Loading -->
27 + <div x-show="$store.pluginInstallStore.loading" class="pi-loading">
28 + <span x-text="$store.pluginInstallStore.loadingMessage || 'Loading...'"></span>
29 + </div>
30 +
31 + <!-- Error -->
32 + <div x-show="$store.pluginInstallStore.error && !$store.pluginInstallStore.loading" class="pi-error">
33 + <span x-text="$store.pluginInstallStore.error"></span>
34 + </div>
35 +
36 + <!-- Plugin Grid -->
37 + <div x-show="$store.pluginInstallStore.index && !$store.pluginInstallStore.loading" class="pi-grid">
38 + <template x-for="plugin in $store.pluginInstallStore.paginatedPlugins" :key="plugin.key">
39 + <div class="pi-card" @click="$store.pluginInstallStore.openDetail(plugin)">
40 + <div class="pi-card-thumb">
41 + <template x-if="plugin.thumbnail">
42 + <img :src="plugin.thumbnail" :alt="plugin.title" loading="lazy">
43 + </template>
44 + <template x-if="!plugin.thumbnail">
45 + <span class="material-symbols-outlined pi-card-placeholder">extension</span>
46 + </template>
47 + </div>
48 + <div class="pi-card-body">
49 + <div class="pi-card-title">
50 + <span x-text="plugin.title || plugin.key"></span>
51 + <template x-if="plugin.installed">
52 + <span class="pi-badge-installed">Installed</span>
53 + </template>
54 + </div>
55 + <div class="pi-card-desc" x-text="$store.pluginInstallStore.truncate(plugin.description, 100)"></div>
56 + </div>
57 + <div class="pi-card-footer">
58 + <span class="pi-stars">
59 + <span class="material-symbols-outlined" style="font-size:0.9rem">star</span>
60 + <span x-text="plugin.stars || 0"></span>
61 + </span>
62 + </div>
63 + </div>
64 + </template>
65 + </div>
66 +
67 + <!-- Empty state -->
68 + <div x-show="$store.pluginInstallStore.index && !$store.pluginInstallStore.loading && $store.pluginInstallStore.filteredPlugins.length === 0"
69 + class="pi-empty">
70 + No plugins found matching your search.
71 + </div>
72 +
73 + <!-- Pagination -->
74 + <div x-show="$store.pluginInstallStore.totalPages > 1" class="pi-pagination">
75 + <button class="button"
76 + :disabled="$store.pluginInstallStore.page <= 1"
77 + @click="$store.pluginInstallStore.setPage($store.pluginInstallStore.page - 1)">
78 + <span class="material-symbols-outlined">chevron_left</span>
79 + </button>
80 + <span class="pi-page-info"
81 + x-text="$store.pluginInstallStore.page + ' / ' + $store.pluginInstallStore.totalPages">
82 + </span>
83 + <button class="button"
84 + :disabled="$store.pluginInstallStore.page >= $store.pluginInstallStore.totalPages"
85 + @click="$store.pluginInstallStore.setPage($store.pluginInstallStore.page + 1)">
86 + <span class="material-symbols-outlined">chevron_right</span>
87 + </button>
88 + </div>
89 +
90 + </div>
91 + </template>
92 + </div>
93 +
94 + <style>
95 + .pi-index-toolbar {
96 + display: flex;
97 + gap: 0.5rem;
98 + margin-bottom: 1rem;
99 + }
100 +
101 + .pi-search-input {
102 + flex: 1;
103 + padding: 0.5rem 0.75rem;
104 + border: 1px solid var(--color-border);
105 + border-radius: 4px;
106 + background: var(--color-bg-primary);
107 + color: var(--color-text-primary);
108 + font-size: 0.95rem;
109 + }
110 +
111 + .pi-sort-select {
112 + padding: 0.5rem;
113 + border: 1px solid var(--color-border);
114 + border-radius: 4px;
115 + background: var(--color-bg-primary);
116 + color: var(--color-text-primary);
117 + font-size: 0.9rem;
118 + }
119 +
120 + .pi-grid {
121 + display: grid;
122 + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
123 + gap: 0.75rem;
124 + }
125 +
126 + .pi-card {
127 + border: 1px solid var(--color-border);
128 + border-radius: 6px;
129 + background: var(--color-background);
130 + cursor: pointer;
131 + transition: border-color 0.15s, box-shadow 0.15s;
132 + display: flex;
133 + flex-direction: column;
134 + overflow: hidden;
135 + }
136 +
137 + .pi-card:hover {
138 + border-color: var(--color-highlight);
139 + box-shadow: 0 2px 8px rgba(0,0,0,0.15);
140 + }
141 +
142 + .pi-card-thumb {
143 + width: 100%;
144 + height: 120px;
145 + overflow: hidden;
146 + display: flex;
147 + align-items: center;
148 + justify-content: center;
149 + background: var(--color-panel);
150 + }
151 +
152 + .pi-card-thumb img {
153 + width: 100%;
154 + height: 100%;
155 + object-fit: cover;
156 + }
157 +
158 + .pi-card-placeholder {
159 + font-size: 3rem;
160 + color: var(--color-text-muted);
161 + }
162 +
163 + .pi-card-body {
164 + padding: 0.6rem 0.75rem;
165 + flex: 1;
166 + }
167 +
168 + .pi-card-title {
169 + font-weight: 600;
170 + font-size: 0.95rem;
171 + display: flex;
172 + align-items: center;
173 + gap: 0.4rem;
174 + flex-wrap: wrap;
175 + }
176 +
177 + .pi-badge-installed {
178 + font-size: 0.7rem;
179 + font-weight: 600;
180 + padding: 0.1rem 0.4rem;
181 + border-radius: 3px;
182 + background: rgba(34,197,94,0.2);
183 + color: #22c55e;
184 + }
185 +
186 + .pi-card-desc {
187 + font-size: 0.8rem;
188 + color: var(--color-text-secondary);
189 + margin-top: 0.3rem;
190 + line-height: 1.3;
191 + }
192 +
193 + .pi-card-footer {
194 + padding: 0.4rem 0.75rem;
195 + border-top: 1px solid var(--color-border);
196 + font-size: 0.8rem;
197 + color: var(--color-text-secondary);
198 + }
199 +
200 + .pi-stars {
201 + display: inline-flex;
202 + align-items: center;
203 + gap: 0.2rem;
204 + }
205 +
206 + .pi-pagination {
207 + display: flex;
208 + align-items: center;
209 + justify-content: center;
210 + gap: 0.75rem;
211 + margin-top: 1rem;
212 + padding: 0.5rem 0;
213 + }
214 +
215 + .pi-page-info {
216 + font-size: 0.9rem;
217 + color: var(--color-text-secondary);
218 + }
219 +
220 + .pi-loading {
221 + text-align: center;
222 + padding: 2rem;
223 + color: var(--color-text-secondary);
224 + }
225 +
226 + .pi-error {
227 + color: var(--color-error);
228 + padding: 0.75rem;
229 + background: var(--color-error-bg, rgba(239,68,68,0.1));
230 + border-radius: 4px;
231 + }
232 +
233 + .pi-empty {
234 + text-align: center;
235 + padding: 2rem;
236 + color: var(--color-text-secondary);
237 + }
238 +
239 + @media (max-width: 500px) {
240 + .pi-grid {
241 + grid-template-columns: 1fr;
242 + }
243 + }
244 + </style>
245 +</body>
246 +</html>
plugins/plugin_installer/webui/install-zip.html new
+129
@@ -0,0 +1,129 @@
1 +<html>
2 +<head>
3 + <title>Install Plugin from ZIP</title>
4 + <script type="module">
5 + import { store } from "/plugins/plugin_installer/webui/pluginInstallStore.js";
6 + </script>
7 +</head>
8 +<body>
9 + <div x-data>
10 + <template x-if="$store.pluginInstallStore">
11 + <div x-init="$store.pluginInstallStore.resetZip()">
12 +
13 + <div class="pi-upload-section">
14 + <label for="plugin-zip-file" class="pi-upload-btn button confirm"
15 + :class="{ 'pi-has-file': $store.pluginInstallStore.zipFile }">
16 + <span class="icon material-symbols-outlined">upload_file</span>
17 + <span x-text="$store.pluginInstallStore.zipFileName || 'Select Plugin ZIP File'"></span>
18 + </label>
19 + <input type="file" id="plugin-zip-file" accept=".zip" style="display:none"
20 + @change="$store.pluginInstallStore.handleFileUpload($event)">
21 + <div class="pi-hint">
22 + The ZIP should contain a folder with a plugin.yaml file.
23 + </div>
24 + </div>
25 +
26 + <div x-show="$store.pluginInstallStore.zipFile" class="pi-actions">
27 + <button class="button confirm"
28 + @click="$store.pluginInstallStore.installZip()"
29 + :disabled="$store.pluginInstallStore.loading">
30 + <span class="icon material-symbols-outlined">download</span> Install Plugin
31 + </button>
32 + </div>
33 +
34 + <div x-show="$store.pluginInstallStore.loading" class="pi-loading">
35 + <span x-text="$store.pluginInstallStore.loadingMessage || 'Processing...'"></span>
36 + </div>
37 +
38 + <div x-show="$store.pluginInstallStore.error" class="pi-error">
39 + <span x-text="$store.pluginInstallStore.error"></span>
40 + </div>
41 +
42 + <div x-show="$store.pluginInstallStore.result" class="pi-result">
43 + <div class="pi-result-icon">
44 + <span class="material-symbols-outlined">check_circle</span>
45 + </div>
46 + <div class="pi-result-text">
47 + <strong x-text="'Plugin installed: ' + ($store.pluginInstallStore.result?.title || $store.pluginInstallStore.result?.plugin_name || '')"></strong>
48 + <div class="pi-result-path" x-text="$store.pluginInstallStore.result?.path || ''"></div>
49 + </div>
50 + </div>
51 +
52 + </div>
53 + </template>
54 + </div>
55 +
56 + <style>
57 + .pi-upload-section {
58 + text-align: center;
59 + padding: 2rem 1rem;
60 + border: 2px dashed var(--color-border);
61 + border-radius: 8px;
62 + margin-bottom: 1rem;
63 + }
64 +
65 + .pi-upload-btn {
66 + display: inline-flex;
67 + align-items: center;
68 + gap: 0.5rem;
69 + padding: 0.75rem 1.5rem;
70 + font-size: 1rem;
71 + cursor: pointer;
72 + }
73 +
74 + .pi-upload-btn.pi-has-file {
75 + background: var(--color-panel);
76 + border-color: var(--color-highlight);
77 + }
78 +
79 + .pi-hint {
80 + margin-top: 0.75rem;
81 + font-size: 0.85rem;
82 + color: var(--color-text-secondary);
83 + }
84 +
85 + .pi-actions {
86 + display: flex;
87 + justify-content: center;
88 + margin: 1rem 0;
89 + }
90 +
91 + .pi-loading {
92 + text-align: center;
93 + padding: 1rem;
94 + color: var(--color-text-secondary);
95 + }
96 +
97 + .pi-error {
98 + color: var(--color-error);
99 + padding: 0.75rem;
100 + background: var(--color-error-bg, rgba(239,68,68,0.1));
101 + border-radius: 4px;
102 + margin-top: 0.75rem;
103 + }
104 +
105 + .pi-result {
106 + display: flex;
107 + align-items: center;
108 + gap: 0.75rem;
109 + padding: 0.75rem;
110 + background: rgba(34,197,94,0.1);
111 + border: 1px solid rgba(34,197,94,0.3);
112 + border-radius: 4px;
113 + margin-top: 0.75rem;
114 + }
115 +
116 + .pi-result-icon .material-symbols-outlined {
117 + font-size: 2rem;
118 + color: #22c55e;
119 + }
120 +
121 + .pi-result-path {
122 + font-size: 0.85rem;
123 + color: var(--color-text-secondary);
124 + margin-top: 0.25rem;
125 + font-family: var(--font-family-code);
126 + }
127 + </style>
128 +</body>
129 +</html>
plugins/plugin_installer/webui/pluginInstallStore.js new
+311
@@ -0,0 +1,311 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import * as api from "/js/api.js";
3 +import { showConfirmDialog } from "/js/confirmDialog.js";
4 +
5 +const PLUGIN_API = "plugins/plugin_installer/plugin_install";
6 +const PER_PAGE = 20;
7 +
8 +const SECURITY_WARNING = {
9 + title: "Security Warning",
10 + message: `
11 + <p><strong>Installing plugins from untrusted sources may pose security risks:</strong></p>
12 + <ul style="margin: 0.75em 0; padding-left: 1.5em;">
13 + <li>Malicious code execution</li>
14 + <li>Exposure of sensitive data</li>
15 + <li>System compromise</li>
16 + </ul>
17 + <p style="margin-top: 0.75em;">Only install plugins from sources you trust.</p>
18 + `,
19 + type: "warning",
20 + confirmText: "Install Anyway",
21 + cancelText: "Cancel",
22 +};
23 +
24 +const model = {
25 + // ZIP install state
26 + zipFile: null,
27 + zipFileName: "",
28 +
29 + // Git install state
30 + gitUrl: "",
31 + gitToken: "",
32 +
33 + // Index state
34 + index: null,
35 + installedPlugins: [],
36 + search: "",
37 + page: 1,
38 + sortBy: "stars",
39 + selectedPlugin: null,
40 +
41 + // Shared state
42 + loading: false,
43 + loadingMessage: "",
44 + error: "",
45 + result: null,
46 +
47 + // ── ZIP Install ──────────────────────────────
48 +
49 + handleFileUpload(event) {
50 + const file = event.target.files[0];
51 + if (!file) return;
52 + this.zipFile = file;
53 + this.zipFileName = file.name;
54 + this.error = "";
55 + this.result = null;
56 + },
57 +
58 + async installZip() {
59 + if (!this.zipFile) {
60 + this.error = "Please select a ZIP file first";
61 + return;
62 + }
63 +
64 + const confirmed = await showConfirmDialog(SECURITY_WARNING);
65 + if (!confirmed) return;
66 +
67 + try {
68 + this.loading = true;
69 + this.loadingMessage = "Installing plugin from ZIP...";
70 + this.error = "";
71 + this.result = null;
72 +
73 + const formData = new FormData();
74 + formData.append("action", "install_zip");
75 + formData.append("plugin_file", this.zipFile);
76 +
77 + const response = await api.fetchApi(PLUGIN_API, {
78 + method: "POST",
79 + body: formData,
80 + });
81 +
82 + const data = await response.json();
83 + if (!data.success) {
84 + this.error = data.error || "Installation failed";
85 + return;
86 + }
87 +
88 + this.result = data;
89 + if (window.toastFrontendSuccess) {
90 + window.toastFrontendSuccess(
91 + `Plugin "${data.title || data.plugin_name}" installed`,
92 + "Plugin Installer"
93 + );
94 + }
95 + this._refreshPluginList();
96 + } catch (e) {
97 + this.error = `Installation error: ${e.message}`;
98 + } finally {
99 + this.loading = false;
100 + this.loadingMessage = "";
101 + }
102 + },
103 +
104 + // ── Git Install ──────────────────────────────
105 +
106 + async installGit() {
107 + const url = (this.gitUrl || "").trim();
108 + if (!url) {
109 + this.error = "Please enter a Git URL";
110 + return;
111 + }
112 +
113 + const confirmed = await showConfirmDialog(SECURITY_WARNING);
114 + if (!confirmed) return;
115 +
116 + try {
117 + this.loading = true;
118 + this.loadingMessage = "Cloning repository...";
119 + this.error = "";
120 + this.result = null;
121 +
122 + const data = await api.callJsonApi(PLUGIN_API, {
123 + action: "install_git",
124 + git_url: url,
125 + git_token: this.gitToken || "",
126 + });
127 +
128 + if (!data.success) {
129 + this.error = data.error || "Clone failed";
130 + return;
131 + }
132 +
133 + this.result = data;
134 + if (window.toastFrontendSuccess) {
135 + window.toastFrontendSuccess(
136 + `Plugin "${data.title || data.plugin_name}" installed`,
137 + "Plugin Installer"
138 + );
139 + }
140 + this._refreshPluginList();
141 + } catch (e) {
142 + this.error = `Clone error: ${e.message}`;
143 + } finally {
144 + this.loading = false;
145 + this.loadingMessage = "";
146 + }
147 + },
148 +
149 + // ── Index Browse ─────────────────────────────
150 +
151 + async fetchIndex() {
152 + try {
153 + this.loading = true;
154 + this.loadingMessage = "Loading plugin index...";
155 + this.error = "";
156 + this.index = null;
157 +
158 + const data = await api.callJsonApi(PLUGIN_API, {
159 + action: "fetch_index",
160 + });
161 +
162 + if (!data.success) {
163 + this.error = data.error || "Failed to load index";
164 + return;
165 + }
166 +
167 + this.index = data.index;
168 + this.installedPlugins = data.installed_plugins || [];
169 + this.page = 1;
170 + } catch (e) {
171 + this.error = `Failed to load plugin index: ${e.message}`;
172 + } finally {
173 + this.loading = false;
174 + this.loadingMessage = "";
175 + }
176 + },
177 +
178 + get pluginsList() {
179 + if (!this.index?.plugins) return [];
180 + return Object.entries(this.index.plugins).map(([key, val]) => ({
181 + key,
182 + ...val,
183 + installed: this.installedPlugins.includes(key),
184 + }));
185 + },
186 +
187 + get filteredPlugins() {
188 + let list = this.pluginsList;
189 + const q = (this.search || "").toLowerCase().trim();
190 + if (q) {
191 + list = list.filter(
192 + (p) =>
193 + (p.title || "").toLowerCase().includes(q) ||
194 + (p.description || "").toLowerCase().includes(q) ||
195 + (p.key || "").toLowerCase().includes(q) ||
196 + (p.tags || []).some((t) => t.toLowerCase().includes(q))
197 + );
198 + }
199 + if (this.sortBy === "stars") {
200 + list.sort((a, b) => (b.stars || 0) - (a.stars || 0));
201 + } else {
202 + list.sort((a, b) =>
203 + (a.title || a.key).localeCompare(b.title || b.key)
204 + );
205 + }
206 + return list;
207 + },
208 +
209 + get totalPages() {
210 + return Math.max(1, Math.ceil(this.filteredPlugins.length / PER_PAGE));
211 + },
212 +
213 + get paginatedPlugins() {
214 + const start = (this.page - 1) * PER_PAGE;
215 + return this.filteredPlugins.slice(start, start + PER_PAGE);
216 + },
217 +
218 + setPage(p) {
219 + this.page = Math.max(1, Math.min(p, this.totalPages));
220 + },
221 +
222 + openDetail(plugin) {
223 + this.selectedPlugin = plugin;
224 + this.error = "";
225 + this.result = null;
226 + window.openModal?.("../plugins/plugin_installer/webui/install-detail.html");
227 + },
228 +
229 + async installFromIndex(plugin) {
230 + if (!plugin?.github) {
231 + this.error = "No GitHub URL available for this plugin";
232 + return;
233 + }
234 +
235 + const confirmed = await showConfirmDialog(SECURITY_WARNING);
236 + if (!confirmed) return;
237 +
238 + try {
239 + this.loading = true;
240 + this.loadingMessage = `Installing ${plugin.title || plugin.key}...`;
241 + this.error = "";
242 + this.result = null;
243 +
244 + const data = await api.callJsonApi(PLUGIN_API, {
245 + action: "install_git",
246 + git_url: plugin.github,
247 + });
248 +
249 + if (!data.success) {
250 + this.error = data.error || "Installation failed";
251 + return;
252 + }
253 +
254 + this.result = data;
255 + if (!this.installedPlugins.includes(plugin.key)) {
256 + this.installedPlugins.push(plugin.key);
257 + }
258 + plugin.installed = true;
259 +
260 + if (window.toastFrontendSuccess) {
261 + window.toastFrontendSuccess(
262 + `Plugin "${data.title || data.plugin_name}" installed`,
263 + "Plugin Installer"
264 + );
265 + }
266 + this._refreshPluginList();
267 + } catch (e) {
268 + this.error = `Installation error: ${e.message}`;
269 + } finally {
270 + this.loading = false;
271 + this.loadingMessage = "";
272 + }
273 + },
274 +
275 + // ── Shared ───────────────────────────────────
276 +
277 + resetZip() {
278 + this.zipFile = null;
279 + this.zipFileName = "";
280 + this.error = "";
281 + this.result = null;
282 + },
283 +
284 + resetGit() {
285 + this.gitUrl = "";
286 + this.gitToken = "";
287 + this.error = "";
288 + this.result = null;
289 + },
290 +
291 + resetIndex() {
292 + this.search = "";
293 + this.page = 1;
294 + this.sortBy = "stars";
295 + this.error = "";
296 + this.result = null;
297 + this.selectedPlugin = null;
298 + },
299 +
300 + _refreshPluginList() {
301 + window.dispatchEvent(new CustomEvent("plugin-modal-closed"));
302 + },
303 +
304 + truncate(text, max) {
305 + if (!text || text.length <= max) return text || "";
306 + return text.substring(0, max) + "...";
307 + },
308 +};
309 +
310 +const store = createStore("pluginInstallStore", model);
311 +export { store };