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 };