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
+ —
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,