handling for plugins init script

Alessandro committed Mar 2, 2026 at 13:16 UTC 8ddd8b3ae76313cb7d514484fe0f1e82ef9a9e3d
6 files changed +326 -1
python/api/plugins.py
+63 -1
@@ -1,4 +1,8 @@
1 +import json
2 import os
3 +import subprocess
4 +import sys
5 +from datetime import datetime, timezone
6
7 from python.helpers.api import ApiHandler, Request, Response
8 from python.helpers import plugins, files
@@ -206,4 +210,62 @@ class Plugins(ApiHandler):
210
211 return {"ok": True, "content": files.read_file(file_path), "filename": filename}
212
209 - return Response(status=400, response=f"Unknown action: {action}")
213 + if action == "run_init_script":
214 + plugin_name = input.get("plugin_name", "")
215 + if not plugin_name:
216 + return Response(status=400, response="Missing plugin_name")
217 +
218 + plugin_dir = plugins.find_plugin_dir(plugin_name)
219 + if not plugin_dir:
220 + return Response(status=404, response="Plugin not found")
221 +
222 + init_script = files.get_abs_path(plugin_dir, "initialize.py")
223 + if not files.exists(init_script):
224 + return Response(status=404, response="initialize.py not found")
225 +
226 + executed_at = datetime.now(timezone.utc).isoformat()
227 + try:
228 + result = subprocess.run(
229 + [sys.executable, init_script],
230 + stdout=subprocess.PIPE,
231 + stderr=subprocess.STDOUT,
232 + text=True,
233 + cwd=plugin_dir,
234 + timeout=120,
235 + )
236 + exit_code = result.returncode
237 + output = result.stdout or ""
238 + except subprocess.TimeoutExpired:
239 + exit_code = -1
240 + output = "Error: script timed out after 120 seconds"
241 + except Exception as e:
242 + exit_code = -1
243 + output = f"Error: {str(e)}"
244 +
245 + exec_record = {"executed_at": executed_at, "exit_code": exit_code}
246 + exec_path = plugins.determine_plugin_asset_path(plugin_name, "", "", "init_exec.json")
247 + if exec_path:
248 + files.write_file(exec_path, json.dumps(exec_record))
249 +
250 + return {
251 + "ok": exit_code == 0,
252 + "output": output,
253 + "exit_code": exit_code,
254 + "executed_at": executed_at,
255 + }
256 +
257 + if action == "get_init_exec":
258 + plugin_name = input.get("plugin_name", "")
259 + if not plugin_name:
260 + return Response(status=400, response="Missing plugin_name")
261 +
262 + exec_path = plugins.determine_plugin_asset_path(plugin_name, "", "", "init_exec.json")
263 + if exec_path and files.exists(exec_path):
264 + try:
265 + data = json.loads(files.read_file(exec_path))
266 + return {"ok": True, "data": data}
267 + except Exception:
268 + pass
269 + return {"ok": True, "data": None}
270 +
271 + return Response(status=400, response=f"Unknown action: {action}")
\ No newline at end of file
python/helpers/plugins.py
+1
@@ -136,6 +136,7 @@ def get_enhanced_plugins_list(
136 has_config_screen=has_config_screen,
137 has_readme=has_readme,
138 has_license=has_license,
139 + has_init_script=has_init_script,
140 toggle_state=toggle_state,
141 )
142 )
webui/components/plugins/list/plugin-init-modal.html new
+173
@@ -0,0 +1,173 @@
1 +<html>
2 +<head>
3 + <title>Plugin Init</title>
4 + <script type="module">
5 + import { store } from "/components/plugins/list/plugin-init-store.js";
6 + </script>
7 +</head>
8 +<body>
9 + <div x-data>
10 + <template x-if="$store.pluginInitStore">
11 + <div x-destroy="$store.pluginInitStore.cleanup()"
12 + x-create="$el.closest('.modal')?.querySelector('.modal-title') && ($el.closest('.modal').querySelector('.modal-title').textContent = 'Init: ' + $store.pluginInitStore.pluginDisplayName)"
13 + class="plugin-init-root">
14 +
15 + <div class="plugin-init-idle"
16 + x-show="!$store.pluginInitStore.running && $store.pluginInitStore.exitCode === null && !$store.pluginInitStore.output">
17 + <span class="material-symbols-outlined idle-icon">terminal</span>
18 + <div class="idle-text">
19 + <span class="idle-headline">Press <strong>Run</strong> to initialize <strong x-text="$store.pluginInitStore.pluginDisplayName"></strong></span>
20 + <span class="idle-sub">This will execute the plugin's <code>initialize.py</code> setup script.</span>
21 + <template x-if="$store.pluginInitStore.lastExec">
22 + <span class="last-exec-info">
23 + Last run:
24 + <span x-text="new Date($store.pluginInitStore.lastExec.executed_at).toLocaleString()"></span>
25 + &nbsp;&mdash;&nbsp;
26 + <span :class="$store.pluginInitStore.lastExec.exit_code === 0 ? 'exit-ok' : 'exit-err'"
27 + x-text="$store.pluginInitStore.lastExec.exit_code === 0 ? 'succeeded' : 'failed (exit ' + $store.pluginInitStore.lastExec.exit_code + ')'">
28 + </span>
29 + </span>
30 + </template>
31 + <template x-if="!$store.pluginInitStore.lastExec">
32 + <span class="last-exec-info never-run">Never initialized</span>
33 + </template>
34 + </div>
35 + </div>
36 +
37 + <div class="plugin-init-status" x-show="$store.pluginInitStore.running">
38 + <span class="material-symbols-outlined spin">progress_activity</span>
39 + <span>Running initialize.py...</span>
40 + </div>
41 +
42 + <div class="plugin-init-exit" x-show="!$store.pluginInitStore.running && $store.pluginInitStore.exitCode !== null">
43 + <span class="material-symbols-outlined"
44 + :class="$store.pluginInitStore.exitCode === 0 ? 'exit-ok' : 'exit-err'"
45 + x-text="$store.pluginInitStore.exitCode === 0 ? 'check_circle' : 'error'">
46 + </span>
47 + <span x-text="$store.pluginInitStore.exitCode === 0 ? 'Completed successfully (exit 0)' : 'Exited with code ' + $store.pluginInitStore.exitCode"></span>
48 + </div>
49 +
50 + <pre class="plugin-init-output"
51 + x-show="$store.pluginInitStore.output || $store.pluginInitStore.running"
52 + x-text="$store.pluginInitStore.output || ''"></pre>
53 + </div>
54 + </template>
55 + </div>
56 +
57 + <div class="modal-footer" data-modal-footer>
58 + <button class="btn btn-action footer-btn"
59 + x-data
60 + :disabled="$store.pluginInitStore?.running"
61 + @click="$store.pluginInitStore?.run()">
62 + <span class="icon material-symbols-outlined">play_arrow</span>
63 + <span x-text="$store.pluginInitStore?.exitCode !== null || $store.pluginInitStore?.output ? 'Rerun' : 'Run'">Run</span>
64 + </button>
65 + <button class="btn btn-cancel footer-btn" @click="window.closeModal?.()">Close</button>
66 + </div>
67 +
68 + <style>
69 + .plugin-init-root {
70 + display: flex;
71 + flex-direction: column;
72 + width: 100%;
73 + }
74 +
75 + .plugin-init-idle {
76 + display: flex;
77 + align-items: flex-start;
78 + gap: 1rem;
79 + padding: 2rem 1.5rem;
80 + color: var(--color-text-secondary);
81 + }
82 +
83 + .idle-icon {
84 + font-size: 2.75rem;
85 + opacity: 0.35;
86 + flex-shrink: 0;
87 + margin-top: 0.15rem;
88 + }
89 +
90 + .idle-text {
91 + display: flex;
92 + flex-direction: column;
93 + gap: 0.4rem;
94 + }
95 +
96 + .idle-headline {
97 + font-size: 1rem;
98 + color: var(--color-text);
99 + line-height: 1.4;
100 + }
101 +
102 + .idle-sub {
103 + font-size: 0.82rem;
104 + opacity: 0.65;
105 + }
106 +
107 + .last-exec-info {
108 + font-size: 0.8rem;
109 + opacity: 0.7;
110 + margin-top: 0.25rem;
111 + }
112 +
113 + .never-run {
114 + font-style: italic;
115 + }
116 +
117 + .plugin-init-status {
118 + display: flex;
119 + align-items: center;
120 + gap: 0.5rem;
121 + padding: 0.75rem 1.5rem;
122 + color: var(--color-text-secondary);
123 + font-size: var(--font-size-small);
124 + }
125 +
126 + .plugin-init-exit {
127 + display: flex;
128 + align-items: center;
129 + gap: 0.5rem;
130 + padding: 0.5rem 1.5rem;
131 + font-size: var(--font-size-small);
132 + }
133 +
134 + .exit-ok {
135 + color: var(--color-success, #27ae60);
136 + }
137 +
138 + .exit-err {
139 + color: var(--color-error, #e74c3c);
140 + }
141 +
142 + .plugin-init-output {
143 + font-family: "Courier New", Courier, monospace;
144 + font-size: 0.82rem;
145 + background: var(--color-bg-secondary, #1e1e1e);
146 + color: var(--color-text-primary);
147 + padding: 1rem 1.5rem;
148 + margin: 0;
149 + overflow-y: auto;
150 + max-height: 60vh;
151 + white-space: pre-wrap;
152 + word-break: break-word;
153 + border-radius: 6px;
154 + }
155 +
156 + @keyframes spin {
157 + from { transform: rotate(0deg); }
158 + to { transform: rotate(360deg); }
159 + }
160 +
161 + .spin {
162 + animation: spin 1s linear infinite;
163 + }
164 +
165 + .footer-btn {
166 + padding: 0.5rem 1.5rem;
167 + font-size: 0.875rem;
168 + font-weight: 500;
169 + border-radius: 0.25rem;
170 + }
171 + </style>
172 +</body>
173 +</html>
webui/components/plugins/list/plugin-init-store.js new
+80
@@ -0,0 +1,80 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import * as api from "/js/api.js";
3 +import {
4 + store as notificationStore,
5 + defaultPriority,
6 +} from "/components/notifications/notification-store.js";
7 +
8 +const model = {
9 + pluginName: "",
10 + pluginDisplayName: "",
11 + output: "",
12 + running: false,
13 + exitCode: null,
14 + lastExec: null,
15 +
16 + async open(plugin) {
17 + if (!plugin?.name) return;
18 + this.pluginName = plugin.name;
19 + this.pluginDisplayName = plugin.display_name || plugin.name;
20 + this.output = "";
21 + this.exitCode = null;
22 + this.lastExec = null;
23 + window.openModal?.("components/plugins/list/plugin-init-modal.html");
24 + await this.fetchLastExec();
25 + },
26 +
27 + async fetchLastExec() {
28 + try {
29 + const response = await api.callJsonApi("plugins", {
30 + action: "get_init_exec",
31 + plugin_name: this.pluginName,
32 + });
33 + this.lastExec = response.data || null;
34 + } catch (e) {
35 + this.lastExec = null;
36 + }
37 + },
38 +
39 + async run() {
40 + if (!this.pluginName) return;
41 + this.output = "";
42 + this.exitCode = null;
43 + this.running = true;
44 + try {
45 + const response = await api.callJsonApi("plugins", {
46 + action: "run_init_script",
47 + plugin_name: this.pluginName,
48 + });
49 + this.output = response.output || "";
50 + this.exitCode = response.exit_code ?? null;
51 + if (response.executed_at) {
52 + this.lastExec = { executed_at: response.executed_at, exit_code: response.exit_code ?? null };
53 + }
54 + } catch (e) {
55 + this.output = e.message || String(e);
56 + this.exitCode = -1;
57 + notificationStore.frontendError(
58 + e.message || String(e),
59 + "Failed to run init script",
60 + 3,
61 + "pluginInit",
62 + defaultPriority,
63 + true,
64 + );
65 + } finally {
66 + this.running = false;
67 + }
68 + },
69 +
70 + cleanup() {
71 + this.pluginName = "";
72 + this.pluginDisplayName = "";
73 + this.output = "";
74 + this.running = false;
75 + this.exitCode = null;
76 + this.lastExec = null;
77 + },
78 +};
79 +
80 +export const store = createStore("pluginInitStore", model);
webui/components/plugins/list/plugin-list.html
+8
@@ -97,6 +97,14 @@
97 <span class="icon material-symbols-outlined">gavel</span> License
98 </button>
99 </template>
100 + <template x-if="plugin.has_init_script">
101 + <button type="button"
102 + class="button"
103 + title="Run initializer script"
104 + @click="$store.pluginInitStore.open(plugin)">
105 + <span class="icon material-symbols-outlined">terminal</span> Init
106 + </button>
107 + </template>
108 <button type="button"
109 class="button"
110 title="Info"
webui/components/plugins/list/pluginListStore.js
+1
@@ -2,6 +2,7 @@ import { createStore } from "/js/AlpineStore.js";
2 import * as api from "/js/api.js";
3 import "/components/plugins/plugin-settings-store.js";
4 import "/components/plugins/toggle/plugin-toggle-store.js";
5 +import "/components/plugins/list/plugin-init-store.js";
6 import "/components/modals/markdown/markdown-store.js";
7 import {
8 store as notificationStore,