1
+import { marked } from "/vendor/marked/marked.esm.js";
2
+import { createStore } from "/js/AlpineStore.js";
3
+import * as api from "/js/api.js";
4
+import { openModal as openAppModal } from "/js/modals.js";
5
+import { toastFrontendError } from "/components/notifications/notification-store.js";
6
+
7
+const BASE = "/plugins/_plugin_validator/webui";
8
+
9
+let _config = null;
10
+let _templateCache = null;
11
+let _guidanceCache = null;
12
+let _pollGen = 0;
13
+let _queue = [];
14
+let _running = null;
15
+const POLL_INTERVAL = 2000;
16
+const MAX_POLL_MS = 10 * 60 * 1000;
17
+
18
+async function fetchText(url, label) {
19
+ const response = await fetch(url);
20
+ if (!response.ok) {
21
+ const body = await response.text().catch(() => "");
22
+ throw new Error(`Failed to load ${label}: ${response.status} ${response.statusText}${body ? ` - ${body}` : ""}`);
23
+ }
24
+ return response.text();
25
+}
26
+
27
+async function fetchJson(url, label) {
28
+ const response = await fetch(url);
29
+ if (!response.ok) {
30
+ const body = await response.text().catch(() => "");
31
+ throw new Error(`Failed to load ${label}: ${response.status} ${response.statusText}${body ? ` - ${body}` : ""}`);
32
+ }
33
+ return response.json();
34
+}
35
+
36
+async function loadConfig() {
37
+ if (_config) return _config;
38
+ try {
39
+ _config = await fetchJson(`${BASE}/plugin-validator-checks.json`, "validator checks");
40
+ return _config;
41
+ } catch (error) {
42
+ _config = null;
43
+ throw error;
44
+ }
45
+}
46
+
47
+async function loadTemplate() {
48
+ if (_templateCache) return _templateCache;
49
+ try {
50
+ _templateCache = await fetchText(`${BASE}/plugin-validator-prompt.md`, "validator prompt template");
51
+ return _templateCache;
52
+ } catch (error) {
53
+ _templateCache = null;
54
+ throw error;
55
+ }
56
+}
57
+
58
+async function loadGuidance() {
59
+ if (_guidanceCache) return _guidanceCache;
60
+ try {
61
+ _guidanceCache = await fetchText(`${BASE}/plugin-validator-guidance.md`, "validator guidance");
62
+ return _guidanceCache;
63
+ } catch (error) {
64
+ _guidanceCache = null;
65
+ throw error;
66
+ }
67
+}
68
+
69
+function formatCriteria(ratings, criteria) {
70
+ return Object.entries(criteria)
71
+ .map(([level, desc]) => `- ${ratings[level].icon} ${desc}`)
72
+ .join("\n");
73
+}
74
+
75
+function formatStatusLegend(ratings) {
76
+ return Object.entries(ratings)
77
+ .map(([, rating]) => `- ${rating.icon} **${rating.label}**`)
78
+ .join("\n");
79
+}
80
+
81
+function formatRatingIcons(ratings) {
82
+ return Object.values(ratings).map((rating) => rating.icon).join("/");
83
+}
84
+
85
+function sourceLabel(source) {
86
+ return {
87
+ local: "Local Plugin",
88
+ git: "Git Repository",
89
+ zip: "Uploaded ZIP",
90
+ }[source] || "Plugin Source";
91
+}
92
+
93
+function sanitizeTarget(value) {
94
+ return String(value || "").trim().replaceAll("{", "(").replaceAll("}", ")");
95
+}
96
+
97
+function targetReference(source, state, overrideTarget = "") {
98
+ if (overrideTarget) return sanitizeTarget(overrideTarget);
99
+
100
+ if (source === "git") {
101
+ return sanitizeTarget(state.gitUrl) || "<paste git URL here>";
102
+ }
103
+
104
+ if (source === "zip") {
105
+ return state.zipFileName
106
+ ? `<uploaded ZIP: ${sanitizeTarget(state.zipFileName)}>`
107
+ : "<uploaded ZIP will be extracted for validation>";
108
+ }
109
+
110
+ return state.localPluginName
111
+ ? `usr/plugins/${sanitizeTarget(state.localPluginName)}/`
112
+ : "<select a local plugin>";
113
+}
114
+
115
+function sourceInstructions(source, state, overrideTarget = "", cleanupTarget = "") {
116
+ const target = targetReference(source, state, overrideTarget);
117
+ const cleanupPath = sanitizeTarget(cleanupTarget) || target;
118
+
119
+ if (source === "git") {
120
+ return `Clone \`${target}\` to a temporary directory outside the workspace, such as \`/tmp/plugin-validate-$(date +%s)\`. Validate the cloned files there. After the review, run \`rm -rf /tmp/plugin-validate-*\` and verify cleanup with \`ls /tmp/plugin-validate-* 2>&1\`.`;
121
+ }
122
+
123
+ if (source === "zip") {
124
+ if (overrideTarget) {
125
+ return `The ZIP has already been extracted to \`${target}\`. Validate the plugin from that extracted directory only. Do not install or move it. After the review, delete that extracted directory with \`rm -rf "${cleanupPath}"\` and verify cleanup with \`ls "${cleanupPath}" 2>&1\`.`;
126
+ }
127
+ return "On run, the selected ZIP will be extracted to a temporary directory for validation. Review the extracted plugin only, do not install it, and delete the extracted directory after the review.";
128
+ }
129
+
130
+ return `Read the plugin directly from \`${target}\`. Do not clone, move, or modify the plugin. No temporary cleanup is required for this source.`;
131
+}
132
+
133
+async function parseJsonResponse(response) {
134
+ const text = await response.text();
135
+ if (!text) return {};
136
+ try {
137
+ return JSON.parse(text);
138
+ } catch {
139
+ return { error: text };
140
+ }
141
+}
142
+
143
+export const store = createStore("pluginValidator", {
144
+ source: "local",
145
+ localPlugins: [],
146
+ localPluginName: "",
147
+ gitUrl: "",
148
+ zipFile: null,
149
+ zipFileName: "",
150
+ checks: {},
151
+ checksMeta: {},
152
+ prompt: "",
153
+ output: "",
154
+ validating: false,
155
+ queued: false,
156
+ validationCtxId: "",
157
+
158
+ get renderedOutput() {
159
+ return this.output ? marked.parse(this.output, { breaks: true }) : "";
160
+ },
161
+
162
+ async init() {
163
+ const cfg = await loadConfig();
164
+ if (!cfg) return;
165
+ this.checksMeta = cfg.checks;
166
+ const initial = {};
167
+ for (const key of Object.keys(cfg.checks)) initial[key] = true;
168
+ this.checks = initial;
169
+ await this.loadLocalPlugins();
170
+ },
171
+
172
+ async loadLocalPlugins() {
173
+ try {
174
+ const response = await api.callJsonApi("plugins_list", {
175
+ filter: { custom: true, builtin: false, search: "" },
176
+ });
177
+ const plugins = Array.isArray(response.plugins) ? response.plugins : [];
178
+ this.localPlugins = plugins
179
+ .filter((plugin) => plugin?.name)
180
+ .sort((a, b) => (a.display_name || a.name || "").localeCompare(b.display_name || b.name || ""));
181
+
182
+ if (!this.localPluginName && this.localPlugins.length) {
183
+ const firstPlugin = this.localPlugins[0];
184
+ this.localPluginName = firstPlugin && typeof firstPlugin === "object" ? firstPlugin["name"] || "" : "";
185
+ }
186
+ } catch (e) {
187
+ const message = e instanceof Error ? e.message : String(e);
188
+ void toastFrontendError(`Failed to load local plugins: ${message}`, "Plugin Validator");
189
+ this.localPlugins = [];
190
+ this.localPluginName = "";
191
+ }
192
+ },
193
+
194
+ applyOptions(options = {}) {
195
+ if (options.source) this.source = options.source;
196
+ if (typeof options.localPluginName === "string") this.localPluginName = options.localPluginName;
197
+ if (typeof options.gitUrl === "string") this.gitUrl = options.gitUrl;
198
+ if (options.zipFile) {
199
+ this.zipFile = options.zipFile;
200
+ this.zipFileName = options.zipFileName || options.zipFile.name || "";
201
+ this.source = "zip";
202
+ }
203
+ },
204
+
205
+ async onOpen() {
206
+ this.output = "";
207
+ this.validating = false;
208
+ this.queued = false;
209
+ this.validationCtxId = "";
210
+ await this.loadLocalPlugins();
211
+
212
+ const cfg = await loadConfig();
213
+ if (cfg && Object.keys(this.checks).length === 0) {
214
+ this.checksMeta = cfg.checks;
215
+ const initial = {};
216
+ for (const key of Object.keys(cfg.checks)) initial[key] = true;
217
+ this.checks = initial;
218
+ }
219
+
220
+ await this.buildPrompt();
221
+ },
222
+
223
+ cleanup() {
224
+ _pollGen++;
225
+ },
226
+
227
+ async openModal(options = {}) {
228
+ this.applyOptions(options);
229
+ await openAppModal("/plugins/_plugin_validator/webui/plugin-validator.html");
230
+ },
231
+
232
+ async setSource(source) {
233
+ this.source = source || "local";
234
+ await this.buildPrompt();
235
+ },
236
+
237
+ async selectLocalPlugin(name) {
238
+ this.localPluginName = name || "";
239
+ await this.buildPrompt();
240
+ },
241
+
242
+ async handleZipUpload(event) {
243
+ const file = event?.target?.files?.[0];
244
+ if (!file) return;
245
+ this.zipFile = file;
246
+ this.zipFileName = file.name || "";
247
+ await this.buildPrompt();
248
+ },
249
+
250
+ async buildPrompt(targetOverride = "", cleanupTargetOverride = "") {
251
+ try {
252
+ const [cfg, template, guidance] = await Promise.all([loadConfig(), loadTemplate(), loadGuidance()]);
253
+ if (!cfg) return;
254
+ const { ratings, checks } = cfg;
255
+
256
+ const selected = Object.entries(this.checks)
257
+ .filter(([, enabled]) => enabled)
258
+ .map(([key]) => checks[key])
259
+ .filter(Boolean);
260
+
261
+ let text = template;
262
+ text = text.replace(/\{\{SOURCE_LABEL\}\}/g, sourceLabel(this.source));
263
+ text = text.replace(/\{\{TARGET_REFERENCE\}\}/g, targetReference(this.source, this, targetOverride));
264
+ text = text.replace(/\{\{SOURCE_INSTRUCTIONS\}\}/g, sourceInstructions(this.source, this, targetOverride, cleanupTargetOverride));
265
+ text = text.replace(
266
+ /\{\{SELECTED_CHECKS\}\}/g,
267
+ selected.length ? selected.map((check) => `- ${check.label}`).join("\n") : "- (no validation phases selected)",
268
+ );
269
+ text = text.replace(
270
+ /\{\{CHECK_DETAILS\}\}/g,
271
+ selected.length
272
+ ? selected
273
+ .map((check) => `**${check.label}**: ${check.detail}\n${formatCriteria(ratings, check.criteria)}`)
274
+ .join("\n\n")
275
+ : "(no validation phases selected)",
276
+ );
277
+ text = text.replace(/\{\{CHECKLIST_GUIDANCE\}\}/g, guidance);
278
+ text = text.replace(/\{\{STATUS_LEGEND\}\}/g, formatStatusLegend(ratings));
279
+ text = text.replace(/\{\{RATING_ICONS\}\}/g, formatRatingIcons(ratings));
280
+ text = text.replace(/\{\{RATING_PASS\}\}/g, ratings.pass.icon);
281
+ text = text.replace(/\{\{RATING_WARNING\}\}/g, ratings.warning.icon);
282
+ text = text.replace(/\{\{RATING_FAIL\}\}/g, ratings.fail.icon);
283
+
284
+ this.prompt = text;
285
+ } catch (e) {
286
+ const message = e instanceof Error ? e.message : String(e);
287
+ void toastFrontendError(`Failed to build prompt: ${message}`, "Plugin Validator");
288
+ }
289
+ },
290
+
291
+ async copyPrompt() {
292
+ try {
293
+ await navigator.clipboard.writeText(this.prompt);
294
+ } catch {
295
+ void toastFrontendError("Failed to copy the validation prompt", "Plugin Validator");
296
+ }
297
+ },
298
+
299
+ async _prepareZipForValidation() {
300
+ if (!this.zipFile) {
301
+ throw new Error("Please select a ZIP file first.");
302
+ }
303
+
304
+ const formData = new FormData();
305
+ formData.append("plugin_file", this.zipFile);
306
+
307
+ const response = await api.fetchApi("/plugins/_plugin_validator/plugin_validator_prepare_zip", {
308
+ method: "POST",
309
+ body: formData,
310
+ });
311
+ const data = await parseJsonResponse(response);
312
+ if (!response.ok || !data.ok) {
313
+ throw new Error(data.error || "ZIP preparation failed.");
314
+ }
315
+
316
+ return data;
317
+ },
318
+
319
+ async runValidation() {
320
+ const selectedChecks = Object.entries(this.checks).filter(([, enabled]) => enabled);
321
+ if (!selectedChecks.length) {
322
+ void toastFrontendError("Select at least one validation phase", "Plugin Validator");
323
+ return;
324
+ }
325
+
326
+ let targetOverride = "";
327
+ let cleanupTargetOverride = "";
328
+ if (this.source === "local") {
329
+ if (!this.localPluginName) {
330
+ void toastFrontendError("Select a local plugin to validate", "Plugin Validator");
331
+ return;
332
+ }
333
+ } else if (this.source === "git") {
334
+ if (!this.gitUrl.trim()) {
335
+ void toastFrontendError("Please enter a Git URL", "Plugin Validator");
336
+ return;
337
+ }
338
+ } else if (this.source === "zip") {
339
+ try {
340
+ const prepared = await this._prepareZipForValidation();
341
+ targetOverride = prepared.path || "";
342
+ cleanupTargetOverride = prepared.cleanup_path || prepared.path || "";
343
+ } catch (e) {
344
+ const message = e instanceof Error ? e.message : String(e);
345
+ void toastFrontendError(message, "Plugin Validator");
346
+ return;
347
+ }
348
+ }
349
+
350
+ await this.buildPrompt(targetOverride, cleanupTargetOverride);
351
+ const capturedPrompt = this.prompt;
352
+ const gen = ++_pollGen;
353
+ this.output = "";
354
+
355
+ let ctxId;
356
+ try {
357
+ const response = await api.callJsonApi("/chat_create", {});
358
+ if (!response.ok) throw new Error("Failed to create chat context");
359
+ ctxId = response.ctxid;
360
+ } catch (e) {
361
+ const message = e instanceof Error ? e.message : String(e);
362
+ void toastFrontendError(`Validation failed: ${message}`, "Plugin Validator");
363
+ return;
364
+ }
365
+ this.validationCtxId = ctxId;
366
+
367
+ if (_running) {
368
+ try {
369
+ await api.callJsonApi("/plugins/_plugin_validator/plugin_validator_queue", {
370
+ context: ctxId,
371
+ text: capturedPrompt,
372
+ queued: true,
373
+ });
374
+ } catch {
375
+ // Best effort only.
376
+ }
377
+ _queue.push({ gen, ctxId, prompt: capturedPrompt });
378
+ this.queued = true;
379
+ this.validating = false;
380
+ } else {
381
+ try {
382
+ await api.callJsonApi("/plugins/_plugin_validator/plugin_validator_queue", {
383
+ context: ctxId,
384
+ text: capturedPrompt,
385
+ });
386
+ } catch {
387
+ // Best effort only.
388
+ }
389
+ this.queued = false;
390
+ this.validating = true;
391
+ this._runNext(gen, ctxId, capturedPrompt);
392
+ }
393
+ },
394
+
395
+ async _runNext(gen, ctxId, prompt) {
396
+ _running = { gen, ctxId };
397
+ try {
398
+ await api.callJsonApi("/plugins/_plugin_validator/plugin_validator_start", {
399
+ text: prompt,
400
+ context: ctxId,
401
+ });
402
+ await this._pollLoop(gen, ctxId);
403
+ } catch (e) {
404
+ if (gen === _pollGen) {
405
+ const message = e instanceof Error ? e.message : String(e);
406
+ void toastFrontendError(`Validation failed: ${message}`, "Plugin Validator");
407
+ this.validating = false;
408
+ this.queued = false;
409
+ }
410
+ } finally {
411
+ _running = null;
412
+ while (_queue.length) {
413
+ const next = _queue.shift();
414
+ if (!next || next.gen !== _pollGen) {
415
+ continue;
416
+ }
417
+ this.queued = false;
418
+ this.validating = true;
419
+ this._runNext(next.gen, next.ctxId, next.prompt);
420
+ break;
421
+ }
422
+ }
423
+ },
424
+
425
+ async _pollLoop(gen, ctxId) {
426
+ let started = false;
427
+ const deadline = Date.now() + MAX_POLL_MS;
428
+ while (true) {
429
+ if (Date.now() >= deadline) {
430
+ if (gen === _pollGen) {
431
+ this.validating = false;
432
+ void toastFrontendError("Validation timed out while waiting for the agent response", "Plugin Validator");
433
+ console.error(`Validation poll timed out for context ${ctxId}`);
434
+ }
435
+ return;
436
+ }
437
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
438
+ try {
439
+ const snapshot = await api.callJsonApi("/poll", {
440
+ context: ctxId,
441
+ log_from: 0,
442
+ notifications_from: 0,
443
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
444
+ });
445
+
446
+ if (gen === _pollGen && snapshot.logs?.length) {
447
+ const last = snapshot.logs
448
+ .filter((log) => log.type === "response" && log.no > 0)
449
+ .pop();
450
+ if (last) this.output = last.content || "";
451
+ }
452
+
453
+ if (snapshot.log_progress_active) started = true;
454
+ if (started && !snapshot.log_progress_active) {
455
+ if (gen === _pollGen) this.validating = false;
456
+ return;
457
+ }
458
+ if (snapshot.deselect_chat) return;
459
+ } catch (e) {
460
+ if (gen === _pollGen) {
461
+ console.error("Validation poll error:", e);
462
+ }
463
+ }
464
+ }
465
+ },
466
+
467
+ openChatInNewWindow() {
468
+ if (!this.validationCtxId) return;
469
+ const url = new URL(window.location.href);
470
+ url.searchParams.set("ctxid", this.validationCtxId);
471
+ window.open(url.toString(), "_blank");
472
+ },
473
+});