@intranefr/superbackend 1.4.4 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/.env.example +5 -0
  2. package/README.md +11 -0
  3. package/index.js +39 -1
  4. package/package.json +11 -3
  5. package/public/sdk/ui-components.iife.js +191 -0
  6. package/sdk/ui-components/browser/src/index.js +228 -0
  7. package/src/admin/endpointRegistry.js +120 -0
  8. package/src/controllers/admin.controller.js +111 -5
  9. package/src/controllers/adminBlockDefinitions.controller.js +127 -0
  10. package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
  11. package/src/controllers/adminCache.controller.js +342 -0
  12. package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
  13. package/src/controllers/adminCrons.controller.js +388 -0
  14. package/src/controllers/adminDbBrowser.controller.js +124 -0
  15. package/src/controllers/adminEjsVirtual.controller.js +13 -3
  16. package/src/controllers/adminHeadless.controller.js +91 -2
  17. package/src/controllers/adminHealthChecks.controller.js +570 -0
  18. package/src/controllers/adminI18n.controller.js +51 -29
  19. package/src/controllers/adminLlm.controller.js +126 -2
  20. package/src/controllers/adminPages.controller.js +720 -0
  21. package/src/controllers/adminPagesContextBlocksAi.controller.js +54 -0
  22. package/src/controllers/adminProxy.controller.js +113 -0
  23. package/src/controllers/adminRateLimits.controller.js +138 -0
  24. package/src/controllers/adminRbac.controller.js +803 -0
  25. package/src/controllers/adminScripts.controller.js +320 -0
  26. package/src/controllers/adminSeoConfig.controller.js +71 -48
  27. package/src/controllers/adminTerminals.controller.js +39 -0
  28. package/src/controllers/adminUiComponents.controller.js +315 -0
  29. package/src/controllers/adminUiComponentsAi.controller.js +34 -0
  30. package/src/controllers/blogAdmin.controller.js +279 -0
  31. package/src/controllers/blogAiAdmin.controller.js +224 -0
  32. package/src/controllers/blogAutomationAdmin.controller.js +141 -0
  33. package/src/controllers/blogInternal.controller.js +26 -0
  34. package/src/controllers/blogPublic.controller.js +89 -0
  35. package/src/controllers/fileManager.controller.js +190 -0
  36. package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
  37. package/src/controllers/healthChecksPublic.controller.js +196 -0
  38. package/src/controllers/metrics.controller.js +64 -4
  39. package/src/controllers/orgAdmin.controller.js +366 -0
  40. package/src/controllers/uiComponentsPublic.controller.js +118 -0
  41. package/src/middleware/auth.js +7 -0
  42. package/src/middleware/internalCronAuth.js +29 -0
  43. package/src/middleware/rbac.js +62 -0
  44. package/src/middleware.js +879 -56
  45. package/src/models/BlockDefinition.js +27 -0
  46. package/src/models/BlogAutomationLock.js +14 -0
  47. package/src/models/BlogAutomationRun.js +39 -0
  48. package/src/models/BlogPost.js +42 -0
  49. package/src/models/CacheEntry.js +26 -0
  50. package/src/models/ConsoleEntry.js +32 -0
  51. package/src/models/ConsoleLog.js +23 -0
  52. package/src/models/ContextBlockDefinition.js +33 -0
  53. package/src/models/CronExecution.js +47 -0
  54. package/src/models/CronJob.js +70 -0
  55. package/src/models/ExternalDbConnection.js +49 -0
  56. package/src/models/FileEntry.js +22 -0
  57. package/src/models/HeadlessModelDefinition.js +10 -0
  58. package/src/models/HealthAutoHealAttempt.js +57 -0
  59. package/src/models/HealthCheck.js +132 -0
  60. package/src/models/HealthCheckRun.js +51 -0
  61. package/src/models/HealthIncident.js +49 -0
  62. package/src/models/Page.js +95 -0
  63. package/src/models/PageCollection.js +42 -0
  64. package/src/models/ProxyEntry.js +66 -0
  65. package/src/models/RateLimitCounter.js +19 -0
  66. package/src/models/RateLimitMetricBucket.js +20 -0
  67. package/src/models/RbacGrant.js +25 -0
  68. package/src/models/RbacGroup.js +16 -0
  69. package/src/models/RbacGroupMember.js +13 -0
  70. package/src/models/RbacGroupRole.js +13 -0
  71. package/src/models/RbacRole.js +25 -0
  72. package/src/models/RbacUserRole.js +13 -0
  73. package/src/models/ScriptDefinition.js +42 -0
  74. package/src/models/ScriptRun.js +22 -0
  75. package/src/models/UiComponent.js +29 -0
  76. package/src/models/UiComponentProject.js +26 -0
  77. package/src/models/UiComponentProjectComponent.js +18 -0
  78. package/src/routes/admin.routes.js +1 -0
  79. package/src/routes/adminBlog.routes.js +21 -0
  80. package/src/routes/adminBlogAi.routes.js +16 -0
  81. package/src/routes/adminBlogAutomation.routes.js +27 -0
  82. package/src/routes/adminCache.routes.js +20 -0
  83. package/src/routes/adminConsoleManager.routes.js +302 -0
  84. package/src/routes/adminCrons.routes.js +25 -0
  85. package/src/routes/adminDbBrowser.routes.js +65 -0
  86. package/src/routes/adminEjsVirtual.routes.js +2 -1
  87. package/src/routes/adminHeadless.routes.js +8 -1
  88. package/src/routes/adminHealthChecks.routes.js +28 -0
  89. package/src/routes/adminI18n.routes.js +4 -3
  90. package/src/routes/adminLlm.routes.js +4 -2
  91. package/src/routes/adminPages.routes.js +55 -0
  92. package/src/routes/adminProxy.routes.js +15 -0
  93. package/src/routes/adminRateLimits.routes.js +17 -0
  94. package/src/routes/adminRbac.routes.js +38 -0
  95. package/src/routes/adminScripts.routes.js +21 -0
  96. package/src/routes/adminSeoConfig.routes.js +5 -4
  97. package/src/routes/adminTerminals.routes.js +13 -0
  98. package/src/routes/adminUiComponents.routes.js +30 -0
  99. package/src/routes/blogInternal.routes.js +14 -0
  100. package/src/routes/blogPublic.routes.js +9 -0
  101. package/src/routes/fileManager.routes.js +62 -0
  102. package/src/routes/fileManagerStoragePolicy.routes.js +9 -0
  103. package/src/routes/healthChecksPublic.routes.js +9 -0
  104. package/src/routes/log.routes.js +43 -60
  105. package/src/routes/metrics.routes.js +4 -2
  106. package/src/routes/orgAdmin.routes.js +6 -0
  107. package/src/routes/pages.routes.js +123 -0
  108. package/src/routes/proxy.routes.js +46 -0
  109. package/src/routes/rbac.routes.js +47 -0
  110. package/src/routes/uiComponentsPublic.routes.js +9 -0
  111. package/src/routes/webhook.routes.js +2 -1
  112. package/src/routes/workflows.routes.js +4 -0
  113. package/src/services/blockDefinitionsAi.service.js +247 -0
  114. package/src/services/blog.service.js +99 -0
  115. package/src/services/blogAutomation.service.js +978 -0
  116. package/src/services/blogCronsBootstrap.service.js +184 -0
  117. package/src/services/blogPublishing.service.js +58 -0
  118. package/src/services/cacheLayer.service.js +696 -0
  119. package/src/services/consoleManager.service.js +700 -0
  120. package/src/services/consoleOverride.service.js +6 -1
  121. package/src/services/cronScheduler.service.js +350 -0
  122. package/src/services/dbBrowser.service.js +536 -0
  123. package/src/services/ejsVirtual.service.js +102 -32
  124. package/src/services/fileManager.service.js +475 -0
  125. package/src/services/fileManagerStoragePolicy.service.js +285 -0
  126. package/src/services/headlessExternalModels.service.js +292 -0
  127. package/src/services/headlessModels.service.js +26 -6
  128. package/src/services/healthChecks.service.js +650 -0
  129. package/src/services/healthChecksBootstrap.service.js +109 -0
  130. package/src/services/healthChecksScheduler.service.js +106 -0
  131. package/src/services/llmDefaults.service.js +190 -0
  132. package/src/services/migrationAssets/s3.js +2 -2
  133. package/src/services/pages.service.js +602 -0
  134. package/src/services/pagesContext.service.js +331 -0
  135. package/src/services/pagesContextBlocksAi.service.js +349 -0
  136. package/src/services/proxy.service.js +535 -0
  137. package/src/services/rateLimiter.service.js +623 -0
  138. package/src/services/rbac.service.js +212 -0
  139. package/src/services/scriptsRunner.service.js +259 -0
  140. package/src/services/terminals.service.js +152 -0
  141. package/src/services/terminalsWs.service.js +100 -0
  142. package/src/services/uiComponentsAi.service.js +299 -0
  143. package/src/services/uiComponentsCrypto.service.js +39 -0
  144. package/src/services/workflow.service.js +23 -8
  145. package/src/utils/orgRoles.js +14 -0
  146. package/src/utils/rbac/engine.js +60 -0
  147. package/src/utils/rbac/rightsRegistry.js +29 -0
  148. package/views/admin-blog-automation.ejs +877 -0
  149. package/views/admin-blog-edit.ejs +542 -0
  150. package/views/admin-blog.ejs +399 -0
  151. package/views/admin-cache.ejs +681 -0
  152. package/views/admin-console-manager.ejs +680 -0
  153. package/views/admin-crons.ejs +645 -0
  154. package/views/admin-db-browser.ejs +445 -0
  155. package/views/admin-ejs-virtual.ejs +16 -10
  156. package/views/admin-file-manager.ejs +942 -0
  157. package/views/admin-headless.ejs +294 -24
  158. package/views/admin-health-checks.ejs +725 -0
  159. package/views/admin-i18n.ejs +59 -5
  160. package/views/admin-llm.ejs +99 -1
  161. package/views/admin-organizations.ejs +528 -10
  162. package/views/admin-pages.ejs +2424 -0
  163. package/views/admin-proxy.ejs +491 -0
  164. package/views/admin-rate-limiter.ejs +625 -0
  165. package/views/admin-rbac.ejs +1331 -0
  166. package/views/admin-scripts.ejs +497 -0
  167. package/views/admin-seo-config.ejs +61 -7
  168. package/views/admin-terminals.ejs +328 -0
  169. package/views/admin-ui-components.ejs +741 -0
  170. package/views/admin-users.ejs +261 -4
  171. package/views/admin-workflows.ejs +7 -7
  172. package/views/file-manager.ejs +866 -0
  173. package/views/pages/blocks/contact.ejs +27 -0
  174. package/views/pages/blocks/cta.ejs +18 -0
  175. package/views/pages/blocks/faq.ejs +20 -0
  176. package/views/pages/blocks/features.ejs +19 -0
  177. package/views/pages/blocks/hero.ejs +13 -0
  178. package/views/pages/blocks/html.ejs +5 -0
  179. package/views/pages/blocks/image.ejs +14 -0
  180. package/views/pages/blocks/testimonials.ejs +26 -0
  181. package/views/pages/blocks/text.ejs +10 -0
  182. package/views/pages/layouts/default.ejs +51 -0
  183. package/views/pages/layouts/minimal.ejs +42 -0
  184. package/views/pages/layouts/sidebar.ejs +54 -0
  185. package/views/pages/partials/footer.ejs +13 -0
  186. package/views/pages/partials/header.ejs +12 -0
  187. package/views/pages/partials/sidebar.ejs +8 -0
  188. package/views/pages/runtime/page.ejs +10 -0
  189. package/views/pages/templates/article.ejs +20 -0
  190. package/views/pages/templates/default.ejs +12 -0
  191. package/views/pages/templates/landing.ejs +14 -0
  192. package/views/pages/templates/listing.ejs +15 -0
  193. package/views/partials/admin-image-upload-modal.ejs +221 -0
  194. package/views/partials/dashboard/nav-items.ejs +14 -0
  195. package/views/partials/llm-provider-model-picker.ejs +183 -0
@@ -0,0 +1,497 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Admin Scripts</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ </head>
9
+ <body class="bg-gray-50">
10
+ <div class="max-w-7xl mx-auto px-6 py-6">
11
+ <div class="flex items-center justify-between mb-6">
12
+ <div>
13
+ <h1 class="text-2xl font-semibold text-gray-900">Scripts</h1>
14
+ <div class="text-sm text-gray-500">Configure and run Bash / Node / Browser scripts</div>
15
+ </div>
16
+ <div class="flex items-center gap-2">
17
+ <button id="btn-new" class="px-3 py-2 rounded bg-blue-600 text-white text-sm hover:bg-blue-700">New</button>
18
+ <button id="btn-save" class="px-3 py-2 rounded bg-gray-900 text-white text-sm hover:bg-black">Save</button>
19
+ <button id="btn-delete" class="px-3 py-2 rounded bg-red-600 text-white text-sm hover:bg-red-700">Delete</button>
20
+ <button id="btn-run" class="px-3 py-2 rounded bg-emerald-600 text-white text-sm hover:bg-emerald-700">Run</button>
21
+ </div>
22
+ </div>
23
+
24
+ <div class="grid grid-cols-12 gap-6">
25
+ <div class="col-span-4">
26
+ <div class="bg-white border border-gray-200 rounded-lg">
27
+ <div class="p-3 border-b border-gray-200 flex items-center justify-between">
28
+ <div class="text-sm font-medium text-gray-800">Scripts</div>
29
+ <button id="btn-refresh" class="text-sm text-blue-600 hover:underline">Refresh</button>
30
+ </div>
31
+ <div id="scripts-list" class="p-2 max-h-[70vh] overflow-auto"></div>
32
+ </div>
33
+
34
+ <div class="mt-4 bg-white border border-gray-200 rounded-lg">
35
+ <div class="p-3 border-b border-gray-200">
36
+ <div class="text-sm font-medium text-gray-800">Runs</div>
37
+ </div>
38
+ <div id="runs-list" class="p-2 max-h-[30vh] overflow-auto"></div>
39
+ </div>
40
+ </div>
41
+
42
+ <div class="col-span-8">
43
+ <div class="bg-white border border-gray-200 rounded-lg">
44
+ <div class="p-4 grid grid-cols-2 gap-4">
45
+ <div>
46
+ <label class="text-xs font-semibold text-gray-600">Name</label>
47
+ <input id="f-name" class="mt-1 w-full border rounded px-3 py-2" placeholder="Update ssh and sync" />
48
+ </div>
49
+ <div>
50
+ <label class="text-xs font-semibold text-gray-600">Code</label>
51
+ <input id="f-code" class="mt-1 w-full border rounded px-3 py-2 font-mono text-sm" placeholder="update-ssh-sync" />
52
+ </div>
53
+ <div class="col-span-2">
54
+ <label class="text-xs font-semibold text-gray-600">Description</label>
55
+ <input id="f-desc" class="mt-1 w-full border rounded px-3 py-2" placeholder="(optional)" />
56
+ </div>
57
+ <div>
58
+ <label class="text-xs font-semibold text-gray-600">Type</label>
59
+ <select id="f-type" class="mt-1 w-full border rounded px-3 py-2">
60
+ <option value="bash">bash</option>
61
+ <option value="node">node</option>
62
+ <option value="browser">browser</option>
63
+ </select>
64
+ </div>
65
+ <div>
66
+ <label class="text-xs font-semibold text-gray-600">Runner</label>
67
+ <select id="f-runner" class="mt-1 w-full border rounded px-3 py-2">
68
+ <option value="host">host</option>
69
+ <option value="vm2">vm2</option>
70
+ <option value="browser">browser</option>
71
+ </select>
72
+ </div>
73
+ <div>
74
+ <label class="text-xs font-semibold text-gray-600">Timeout (ms)</label>
75
+ <input id="f-timeout" type="number" class="mt-1 w-full border rounded px-3 py-2" />
76
+ </div>
77
+ <div>
78
+ <label class="text-xs font-semibold text-gray-600">Working directory</label>
79
+ <input id="f-cwd" class="mt-1 w-full border rounded px-3 py-2 font-mono text-sm" placeholder="(optional)" />
80
+ </div>
81
+ <div class="col-span-2">
82
+ <label class="text-xs font-semibold text-gray-600">Enabled</label>
83
+ <div class="mt-2 flex items-center gap-2">
84
+ <input id="f-enabled" type="checkbox" class="h-4 w-4" />
85
+ <span class="text-sm text-gray-700" title="When disabled, the server will reject any attempt to run this script (UI, API, and scheduled runs).">Script can be run</span>
86
+ </div>
87
+ </div>
88
+ </div>
89
+
90
+ <div class="border-t border-gray-200 p-4">
91
+ <div class="flex items-center justify-between mb-2">
92
+ <div class="text-sm font-medium text-gray-800">Environment</div>
93
+ <button id="btn-add-env" class="text-sm text-blue-600 hover:underline">Add</button>
94
+ </div>
95
+ <div class="overflow-auto">
96
+ <table class="w-full text-sm">
97
+ <thead>
98
+ <tr class="text-left text-gray-500">
99
+ <th class="py-1 pr-2">Key</th>
100
+ <th class="py-1 pr-2">Value</th>
101
+ <th class="py-1 w-12"></th>
102
+ </tr>
103
+ </thead>
104
+ <tbody id="env-tbody"></tbody>
105
+ </table>
106
+ </div>
107
+ </div>
108
+
109
+ <div class="border-t border-gray-200 p-4">
110
+ <div class="text-sm font-medium text-gray-800 mb-2">Script</div>
111
+ <textarea id="f-script" class="w-full h-56 border rounded px-3 py-2 font-mono text-sm" placeholder="#!/usr/bin/env bash\n..."></textarea>
112
+ <div id="runner-warning" class="mt-2 text-xs text-amber-700"></div>
113
+ </div>
114
+ </div>
115
+
116
+ <div class="mt-4 bg-white border border-gray-200 rounded-lg">
117
+ <div class="p-3 border-b border-gray-200 flex items-center justify-between">
118
+ <div class="text-sm font-medium text-gray-800">Output</div>
119
+ <button id="btn-clear-output" class="text-sm text-gray-600 hover:underline">Clear</button>
120
+ </div>
121
+ <pre id="output" class="p-3 text-xs font-mono whitespace-pre-wrap max-h-[40vh] overflow-auto"></pre>
122
+ </div>
123
+ </div>
124
+ </div>
125
+ </div>
126
+
127
+ <script>
128
+ window.BASE_URL = '<%= baseUrl %>';
129
+ window.ADMIN_PATH = '<%= adminPath %>';
130
+
131
+ const state = {
132
+ scripts: [],
133
+ runs: [],
134
+ selected: null,
135
+ selectedId: null,
136
+ es: null,
137
+ };
138
+
139
+ function qs(id) {
140
+ return document.getElementById(id);
141
+ }
142
+
143
+ function toast(msg) {
144
+ alert(msg);
145
+ }
146
+
147
+ async function api(path, opts) {
148
+ const baseUrl = window.BASE_URL || '';
149
+ const url = baseUrl + path;
150
+ const res = await fetch(url, {
151
+ credentials: 'same-origin',
152
+ headers: {
153
+ 'Content-Type': 'application/json',
154
+ },
155
+ ...opts,
156
+ });
157
+ const json = await res.json().catch(() => ({}));
158
+ if (!res.ok) {
159
+ throw new Error(json.error || 'Request failed');
160
+ }
161
+ return json;
162
+ }
163
+
164
+ function setOutput(text, append = false) {
165
+ const el = qs('output');
166
+ if (!append) el.textContent = '';
167
+ el.textContent += String(text || '');
168
+ el.scrollTop = el.scrollHeight;
169
+ }
170
+
171
+ function setRunnerWarning() {
172
+ const type = qs('f-type').value;
173
+ const runner = qs('f-runner').value;
174
+ const el = qs('runner-warning');
175
+ let msg = '';
176
+ if (type === 'bash' && runner === 'host') {
177
+ msg = 'Warning: bash host runner executes on the server host.';
178
+ }
179
+ if (type === 'node' && runner === 'host') {
180
+ msg = 'Warning: node host runner executes on the server host.';
181
+ }
182
+ if (type === 'node' && runner === 'vm2') {
183
+ msg = 'vm2 mode is best-effort and does not support arbitrary Node APIs.';
184
+ }
185
+ if (type === 'browser') {
186
+ msg = 'Browser scripts run in this page only.';
187
+ }
188
+ el.textContent = msg;
189
+ }
190
+
191
+ function normalizeRunnerOptions() {
192
+ const type = qs('f-type').value;
193
+ const runnerSelect = qs('f-runner');
194
+ const runner = runnerSelect.value;
195
+ const allowed =
196
+ type === 'bash' ? ['host'] :
197
+ type === 'node' ? ['host', 'vm2'] :
198
+ ['browser'];
199
+
200
+ runnerSelect.innerHTML = allowed
201
+ .map((v) => `<option value="${v}">${v}</option>`)
202
+ .join('');
203
+
204
+ if (allowed.includes(runner)) runnerSelect.value = runner;
205
+ setRunnerWarning();
206
+ }
207
+
208
+ function currentPayload() {
209
+ const env = [];
210
+ qs('env-tbody').querySelectorAll('tr').forEach((tr) => {
211
+ const key = tr.querySelector('.env-key').value.trim();
212
+ const value = tr.querySelector('.env-val').value;
213
+ if (!key) return;
214
+ env.push({ key, value });
215
+ });
216
+
217
+ return {
218
+ name: qs('f-name').value.trim(),
219
+ codeIdentifier: qs('f-code').value.trim(),
220
+ description: qs('f-desc').value,
221
+ type: qs('f-type').value,
222
+ runner: qs('f-runner').value,
223
+ timeoutMs: Number(qs('f-timeout').value || 0) || undefined,
224
+ defaultWorkingDirectory: qs('f-cwd').value,
225
+ enabled: !!qs('f-enabled').checked,
226
+ env,
227
+ script: qs('f-script').value,
228
+ };
229
+ }
230
+
231
+ function renderEnv(env) {
232
+ const tbody = qs('env-tbody');
233
+ tbody.innerHTML = '';
234
+ const items = Array.isArray(env) ? env : [];
235
+ for (const item of items) {
236
+ const tr = document.createElement('tr');
237
+ tr.innerHTML = `
238
+ <td class="py-1 pr-2"><input class="env-key w-full border rounded px-2 py-1 font-mono text-xs" value="${String(item.key || '').replace(/</g,'&lt;')}" /></td>
239
+ <td class="py-1 pr-2"><input class="env-val w-full border rounded px-2 py-1 font-mono text-xs" value="${String(item.value || '').replace(/</g,'&lt;')}" /></td>
240
+ <td class="py-1 text-right"><button class="btn-del-env text-red-600 hover:underline text-xs">Del</button></td>
241
+ `;
242
+ tbody.appendChild(tr);
243
+ tr.querySelector('.btn-del-env').addEventListener('click', () => {
244
+ tr.remove();
245
+ });
246
+ }
247
+ }
248
+
249
+ function clearForm() {
250
+ state.selectedId = null;
251
+ state.selected = { enabled: true, timeoutMs: 300000, type: 'bash', runner: 'host', env: [] };
252
+ qs('f-name').value = '';
253
+ qs('f-code').value = '';
254
+ qs('f-desc').value = '';
255
+ qs('f-type').value = 'bash';
256
+ normalizeRunnerOptions();
257
+ qs('f-timeout').value = '300000';
258
+ qs('f-cwd').value = '';
259
+ qs('f-enabled').checked = true;
260
+ qs('f-script').value = '';
261
+ renderEnv([]);
262
+ renderRuns();
263
+ }
264
+
265
+ function setFormFromScript(s) {
266
+ state.selectedId = s._id;
267
+ state.selected = s;
268
+ qs('f-name').value = s.name || '';
269
+ qs('f-code').value = s.codeIdentifier || '';
270
+ qs('f-desc').value = s.description || '';
271
+ qs('f-type').value = s.type || 'bash';
272
+ normalizeRunnerOptions();
273
+ qs('f-runner').value = s.runner || (s.type === 'bash' ? 'host' : 'host');
274
+ qs('f-timeout').value = String(s.timeoutMs || 300000);
275
+ qs('f-cwd').value = s.defaultWorkingDirectory || '';
276
+ qs('f-enabled').checked = !!s.enabled;
277
+ qs('f-script').value = s.script || '';
278
+ renderEnv(s.env || []);
279
+ loadRuns();
280
+ }
281
+
282
+ function renderScripts() {
283
+ const list = qs('scripts-list');
284
+ list.innerHTML = '';
285
+ if (!state.scripts.length) {
286
+ list.innerHTML = '<div class="text-sm text-gray-500 p-2">No scripts</div>';
287
+ return;
288
+ }
289
+ state.scripts.forEach((s) => {
290
+ const btn = document.createElement('button');
291
+ const active = state.selectedId === s._id;
292
+ btn.className = `w-full text-left px-3 py-2 rounded border ${active ? 'bg-blue-50 border-blue-200' : 'bg-white border-gray-200 hover:bg-gray-50'} mb-2`;
293
+ btn.innerHTML = `
294
+ <div class="flex items-center justify-between gap-2">
295
+ <div class="font-medium text-gray-900">${String(s.name || '').replace(/</g,'&lt;')}</div>
296
+ <div class="text-[10px] uppercase bg-gray-200 text-gray-800 px-2 py-0.5 rounded">${String(s.type || '').replace(/</g,'&lt;')}</div>
297
+ </div>
298
+ <div class="text-xs text-gray-500 font-mono">${String(s.codeIdentifier || '').replace(/</g,'&lt;')} · ${String(s.runner || '').replace(/</g,'&lt;')}</div>
299
+ `;
300
+ btn.addEventListener('click', () => setFormFromScript(s));
301
+ list.appendChild(btn);
302
+ });
303
+ }
304
+
305
+ function renderRuns() {
306
+ const list = qs('runs-list');
307
+ list.innerHTML = '';
308
+ if (!state.runs.length) {
309
+ list.innerHTML = '<div class="text-sm text-gray-500 p-2">No runs</div>';
310
+ return;
311
+ }
312
+ state.runs.forEach((r) => {
313
+ const btn = document.createElement('button');
314
+ btn.className = 'w-full text-left px-3 py-2 rounded border bg-white border-gray-200 hover:bg-gray-50 mb-2';
315
+ btn.innerHTML = `
316
+ <div class="flex items-center justify-between">
317
+ <div class="text-sm font-medium">${String(r.status || '').replace(/</g,'&lt;')}</div>
318
+ <div class="text-xs text-gray-500">${r.exitCode === null || r.exitCode === undefined ? '' : 'exit ' + r.exitCode}</div>
319
+ </div>
320
+ <div class="text-xs text-gray-500 font-mono">${String(r._id || '').slice(0, 10)} · ${String(r.createdAt || '').replace(/</g,'&lt;')}</div>
321
+ `;
322
+ btn.addEventListener('click', () => {
323
+ setOutput('');
324
+ if (r.outputTail) setOutput(r.outputTail, true);
325
+ });
326
+ list.appendChild(btn);
327
+ });
328
+ }
329
+
330
+ async function loadScripts() {
331
+ const json = await api('/api/admin/scripts');
332
+ state.scripts = json.items || [];
333
+ renderScripts();
334
+ }
335
+
336
+ async function loadRuns() {
337
+ if (!state.selectedId) {
338
+ state.runs = [];
339
+ renderRuns();
340
+ return;
341
+ }
342
+ const json = await api('/api/admin/scripts/runs?scriptId=' + encodeURIComponent(state.selectedId));
343
+ state.runs = json.items || [];
344
+ renderRuns();
345
+ }
346
+
347
+ async function saveScript() {
348
+ const payload = currentPayload();
349
+ if (!payload.name) throw new Error('name is required');
350
+ if (!payload.codeIdentifier) throw new Error('codeIdentifier is required');
351
+ if (!payload.type) throw new Error('type is required');
352
+ if (!payload.runner) throw new Error('runner is required');
353
+
354
+ if (payload.type === 'bash' && payload.runner !== 'host') {
355
+ throw new Error('bash runner must be host');
356
+ }
357
+ if (payload.type === 'browser' && payload.runner !== 'browser') {
358
+ throw new Error('browser runner must be browser');
359
+ }
360
+
361
+ if (!state.selectedId) {
362
+ const res = await api('/api/admin/scripts', { method: 'POST', body: JSON.stringify(payload) });
363
+ toast('Created');
364
+ await loadScripts();
365
+ setFormFromScript(res.item);
366
+ } else {
367
+ const res = await api('/api/admin/scripts/' + encodeURIComponent(state.selectedId), { method: 'PUT', body: JSON.stringify(payload) });
368
+ toast('Saved');
369
+ await loadScripts();
370
+ setFormFromScript(res.item);
371
+ }
372
+ }
373
+
374
+ async function deleteScript() {
375
+ if (!state.selectedId) return;
376
+ if (!confirm('Delete script?')) return;
377
+ await api('/api/admin/scripts/' + encodeURIComponent(state.selectedId), { method: 'DELETE' });
378
+ toast('Deleted');
379
+ clearForm();
380
+ await loadScripts();
381
+ }
382
+
383
+ function closeStream() {
384
+ if (state.es) {
385
+ try { state.es.close(); } catch {}
386
+ }
387
+ state.es = null;
388
+ }
389
+
390
+ function startSse(runId) {
391
+ closeStream();
392
+ const baseUrl = window.BASE_URL || '';
393
+ const url = baseUrl + '/api/admin/scripts/runs/' + encodeURIComponent(runId) + '/stream';
394
+ const es = new EventSource(url);
395
+ state.es = es;
396
+
397
+ const onLog = (e) => {
398
+ const data = JSON.parse(e.data || '{}');
399
+ setOutput(data.line || '', true);
400
+ };
401
+ const onStatus = (e) => {
402
+ const data = JSON.parse(e.data || '{}');
403
+ setOutput(`\n[status] ${data.status}${data.exitCode === undefined ? '' : ' (exit ' + data.exitCode + ')'}\n`, true);
404
+ loadRuns();
405
+ };
406
+ const onDone = () => {
407
+ closeStream();
408
+ };
409
+
410
+ es.addEventListener('log', onLog);
411
+ es.addEventListener('status', onStatus);
412
+ es.addEventListener('done', onDone);
413
+ es.onerror = () => {
414
+ setOutput('\n[stream] disconnected\n', true);
415
+ closeStream();
416
+ };
417
+ }
418
+
419
+ async function runScript() {
420
+ const payload = currentPayload();
421
+
422
+ if (payload.type === 'browser') {
423
+ setOutput('');
424
+ const originalLog = console.log;
425
+ const originalErr = console.error;
426
+ try {
427
+ console.log = (...args) => setOutput(args.join(' ') + '\n', true);
428
+ console.error = (...args) => setOutput(args.join(' ') + '\n', true);
429
+ const fn = new Function(payload.script);
430
+ fn();
431
+ setOutput('\n[done] ok\n', true);
432
+ } finally {
433
+ console.log = originalLog;
434
+ console.error = originalErr;
435
+ }
436
+ return;
437
+ }
438
+
439
+ if (!state.selectedId) {
440
+ await saveScript();
441
+ }
442
+
443
+ setOutput('');
444
+ const res = await api('/api/admin/scripts/' + encodeURIComponent(state.selectedId) + '/run', { method: 'POST' });
445
+ startSse(res.runId);
446
+ await loadRuns();
447
+ }
448
+
449
+ qs('btn-refresh').addEventListener('click', loadScripts);
450
+ qs('btn-new').addEventListener('click', clearForm);
451
+ qs('btn-save').addEventListener('click', async () => {
452
+ try {
453
+ await saveScript();
454
+ } catch (e) {
455
+ toast(e.message);
456
+ }
457
+ });
458
+ qs('btn-delete').addEventListener('click', async () => {
459
+ try {
460
+ await deleteScript();
461
+ } catch (e) {
462
+ toast(e.message);
463
+ }
464
+ });
465
+ qs('btn-run').addEventListener('click', async () => {
466
+ try {
467
+ await runScript();
468
+ } catch (e) {
469
+ toast(e.message);
470
+ }
471
+ });
472
+ qs('btn-clear-output').addEventListener('click', () => setOutput(''));
473
+ qs('btn-add-env').addEventListener('click', () => {
474
+ const tbody = qs('env-tbody');
475
+ const tr = document.createElement('tr');
476
+ tr.innerHTML = `
477
+ <td class="py-1 pr-2"><input class="env-key w-full border rounded px-2 py-1 font-mono text-xs" /></td>
478
+ <td class="py-1 pr-2"><input class="env-val w-full border rounded px-2 py-1 font-mono text-xs" /></td>
479
+ <td class="py-1 text-right"><button class="btn-del-env text-red-600 hover:underline text-xs">Del</button></td>
480
+ `;
481
+ tbody.appendChild(tr);
482
+ tr.querySelector('.btn-del-env').addEventListener('click', () => tr.remove());
483
+ });
484
+
485
+ qs('f-type').addEventListener('change', () => {
486
+ normalizeRunnerOptions();
487
+ });
488
+ qs('f-runner').addEventListener('change', setRunnerWarning);
489
+
490
+ (async function init() {
491
+ clearForm();
492
+ normalizeRunnerOptions();
493
+ await loadScripts();
494
+ })();
495
+ </script>
496
+ </body>
497
+ </html>
@@ -102,6 +102,15 @@
102
102
  <label class="block text-sm font-medium">Route path</label>
103
103
  <input id="seo-ai-route" class="mt-1 w-full border rounded px-3 py-2 text-sm" placeholder="/marketplace" />
104
104
  </div>
105
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
106
+ <%- include('partials/llm-provider-model-picker', {
107
+ providerInputId: 'seoAiProviderKey',
108
+ modelInputId: 'seoAiModel',
109
+ providerLabel: 'Provider',
110
+ modelLabel: 'Model',
111
+ showOpenRouterFetch: true,
112
+ }) %>
113
+ </div>
105
114
  <div class="flex gap-2">
106
115
  <button class="px-3 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 text-sm" onclick="seoAiGenerateFromView()">Generate entry</button>
107
116
  <button class="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm" onclick="seoAiApplyProposed()">Apply</button>
@@ -124,6 +133,15 @@
124
133
  <label class="block text-sm font-medium">Instruction</label>
125
134
  <input id="seo-ai-instruction" class="mt-1 w-full border rounded px-3 py-2 text-sm" placeholder="Improve description including desktop apps" />
126
135
  </div>
136
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
137
+ <%- include('partials/llm-provider-model-picker', {
138
+ providerInputId: 'seoAiImproveProviderKey',
139
+ modelInputId: 'seoAiImproveModel',
140
+ providerLabel: 'Provider',
141
+ modelLabel: 'Model',
142
+ showOpenRouterFetch: true,
143
+ }) %>
144
+ </div>
127
145
  <div class="flex gap-2">
128
146
  <label class="inline-flex items-center gap-2 text-sm text-gray-700">
129
147
  <input id="seo-ai-robots-noindex" type="checkbox" class="rounded border-gray-300" />
@@ -219,9 +237,16 @@
219
237
  </div>
220
238
 
221
239
  <div class="mb-3">
222
- <label class="block text-sm font-medium mb-1">Model (optional override)</label>
223
- <input id="ai-model" class="w-full border rounded px-3 py-2 text-sm" placeholder="google/gemini-2.5-flash-lite" />
224
- <div class="mt-1 text-xs text-gray-600">Uses <code>seoconfig.ai.openrouter.*</code> settings with fallback to <code>ai.openrouter.*</code>. Disabled if no API key.</div>
240
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
241
+ <%- include('partials/llm-provider-model-picker', {
242
+ providerInputId: 'ogAiProviderKey',
243
+ modelInputId: 'ai-model',
244
+ providerLabel: 'Provider',
245
+ modelLabel: 'Model (optional override)',
246
+ showOpenRouterFetch: true,
247
+ }) %>
248
+ </div>
249
+ <div class="mt-1 text-xs text-gray-600">Legacy OpenRouter keys (<code>seoconfig.ai.openrouter.apiKey</code> / <code>ai.openrouter.apiKey</code>) are still supported when provider is <code>openrouter</code>.</div>
225
250
  </div>
226
251
 
227
252
  <div class="flex justify-end gap-2">
@@ -233,7 +258,15 @@
233
258
  </div>
234
259
 
235
260
  <script>
236
- const API_BASE = window.location.origin + "<%= baseUrl %>";
261
+ const API_BASE = window.location.origin + "<%= baseUrl || '' %>";
262
+
263
+ function initLlmPickers() {
264
+ if (!window.__llmProviderModelPicker || !window.__llmProviderModelPicker.init) return;
265
+
266
+ window.__llmProviderModelPicker.init({ apiBase: API_BASE, providerInputId: 'seoAiProviderKey', modelInputId: 'seoAiModel' });
267
+ window.__llmProviderModelPicker.init({ apiBase: API_BASE, providerInputId: 'seoAiImproveProviderKey', modelInputId: 'seoAiImproveModel' });
268
+ window.__llmProviderModelPicker.init({ apiBase: API_BASE, providerInputId: 'ogAiProviderKey', modelInputId: 'ai-model' });
269
+ }
237
270
 
238
271
  function showToast(message, type = 'success') {
239
272
  const container = document.getElementById('toast-container');
@@ -409,10 +442,17 @@
409
442
  try {
410
443
  const viewPath = document.getElementById('seo-ai-view').value;
411
444
  const routePath = document.getElementById('seo-ai-route').value;
445
+ const providerKey = document.getElementById('seoAiProviderKey')?.value;
446
+ const model = document.getElementById('seoAiModel')?.value;
412
447
  const res = await fetch(`${API_BASE}/api/admin/seo-config/ai/generate-entry`, {
413
448
  method: 'POST',
414
449
  headers: { 'Content-Type': 'application/json' },
415
- body: JSON.stringify({ viewPath, routePath }),
450
+ body: JSON.stringify({
451
+ viewPath,
452
+ routePath,
453
+ providerKey: providerKey ? String(providerKey).trim() : undefined,
454
+ model: model ? String(model).trim() : undefined,
455
+ }),
416
456
  });
417
457
  const data = await res.json().catch(() => ({}));
418
458
  if (!res.ok) throw new Error(data?.error || 'Failed to generate entry');
@@ -432,10 +472,17 @@
432
472
  try {
433
473
  const routePath = document.getElementById('seo-ai-existing-route').value;
434
474
  const instruction = document.getElementById('seo-ai-instruction').value;
475
+ const providerKey = document.getElementById('seoAiImproveProviderKey')?.value;
476
+ const model = document.getElementById('seoAiImproveModel')?.value;
435
477
  const res = await fetch(`${API_BASE}/api/admin/seo-config/ai/improve-entry`, {
436
478
  method: 'POST',
437
479
  headers: { 'Content-Type': 'application/json' },
438
- body: JSON.stringify({ routePath, instruction }),
480
+ body: JSON.stringify({
481
+ routePath,
482
+ instruction,
483
+ providerKey: providerKey ? String(providerKey).trim() : undefined,
484
+ model: model ? String(model).trim() : undefined,
485
+ }),
439
486
  });
440
487
  const data = await res.json().catch(() => ({}));
441
488
  if (!res.ok) throw new Error(data?.error || 'Failed to improve entry');
@@ -617,6 +664,7 @@
617
664
  document.getElementById('ai-instruction').value = '';
618
665
  document.getElementById('ai-model').value = '';
619
666
  document.getElementById('ai-modal').classList.remove('hidden');
667
+ initLlmPickers();
620
668
  }
621
669
 
622
670
  function closeAiModal() {
@@ -634,11 +682,17 @@
634
682
  const svgRaw = document.getElementById('og-svg-raw').value;
635
683
  const instruction = document.getElementById('ai-instruction').value;
636
684
  const model = document.getElementById('ai-model').value;
685
+ const providerKey = document.getElementById('ogAiProviderKey')?.value;
637
686
 
638
687
  const res = await fetch(`${API_BASE}/api/admin/seo-config/ai/edit-svg`, {
639
688
  method: 'POST',
640
689
  headers: { 'Content-Type': 'application/json' },
641
- body: JSON.stringify({ svgRaw, instruction, model: model || undefined }),
690
+ body: JSON.stringify({
691
+ svgRaw,
692
+ instruction,
693
+ providerKey: providerKey ? String(providerKey).trim() : undefined,
694
+ model: model || undefined,
695
+ }),
642
696
  });
643
697
  const data = await res.json().catch(() => ({}));
644
698
  if (!res.ok) throw new Error(data?.error || 'AI failed');