@intranefr/superbackend 1.4.3

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 (188) hide show
  1. package/.commiat +4 -0
  2. package/.env.example +47 -0
  3. package/README.md +110 -0
  4. package/index.js +94 -0
  5. package/package.json +67 -0
  6. package/public/css/styles.css +139 -0
  7. package/public/js/animations.js +41 -0
  8. package/sdk/error-tracking/browser/package.json +16 -0
  9. package/sdk/error-tracking/browser/src/core.js +270 -0
  10. package/sdk/error-tracking/browser/src/embed.js +18 -0
  11. package/sdk/error-tracking/browser/src/index.js +1 -0
  12. package/server.js +5 -0
  13. package/src/admin/endpointRegistry.js +300 -0
  14. package/src/controllers/admin.controller.js +321 -0
  15. package/src/controllers/adminAssets.controller.js +530 -0
  16. package/src/controllers/adminAssetsStorage.controller.js +260 -0
  17. package/src/controllers/adminEjsVirtual.controller.js +354 -0
  18. package/src/controllers/adminFeatureFlags.controller.js +155 -0
  19. package/src/controllers/adminHeadless.controller.js +1071 -0
  20. package/src/controllers/adminI18n.controller.js +604 -0
  21. package/src/controllers/adminJsonConfigs.controller.js +97 -0
  22. package/src/controllers/adminLlm.controller.js +273 -0
  23. package/src/controllers/adminMigration.controller.js +257 -0
  24. package/src/controllers/adminSeoConfig.controller.js +515 -0
  25. package/src/controllers/adminStats.controller.js +121 -0
  26. package/src/controllers/adminUploadNamespaces.controller.js +208 -0
  27. package/src/controllers/assets.controller.js +248 -0
  28. package/src/controllers/auth.controller.js +93 -0
  29. package/src/controllers/billing.controller.js +223 -0
  30. package/src/controllers/featureFlags.controller.js +35 -0
  31. package/src/controllers/forms.controller.js +217 -0
  32. package/src/controllers/globalSettings.controller.js +252 -0
  33. package/src/controllers/headlessCrud.controller.js +126 -0
  34. package/src/controllers/i18n.controller.js +12 -0
  35. package/src/controllers/invite.controller.js +249 -0
  36. package/src/controllers/jsonConfigs.controller.js +19 -0
  37. package/src/controllers/metrics.controller.js +149 -0
  38. package/src/controllers/notificationAdmin.controller.js +264 -0
  39. package/src/controllers/notifications.controller.js +131 -0
  40. package/src/controllers/org.controller.js +357 -0
  41. package/src/controllers/orgAdmin.controller.js +491 -0
  42. package/src/controllers/stripeAdmin.controller.js +410 -0
  43. package/src/controllers/user.controller.js +361 -0
  44. package/src/controllers/userAdmin.controller.js +277 -0
  45. package/src/controllers/waitingList.controller.js +167 -0
  46. package/src/controllers/webhook.controller.js +200 -0
  47. package/src/middleware/auth.js +66 -0
  48. package/src/middleware/errorCapture.js +170 -0
  49. package/src/middleware/headlessApiTokenAuth.js +57 -0
  50. package/src/middleware/org.js +108 -0
  51. package/src/middleware.js +901 -0
  52. package/src/models/ActionEvent.js +31 -0
  53. package/src/models/ActivityLog.js +41 -0
  54. package/src/models/Asset.js +84 -0
  55. package/src/models/AuditEvent.js +93 -0
  56. package/src/models/EmailLog.js +28 -0
  57. package/src/models/ErrorAggregate.js +72 -0
  58. package/src/models/FormSubmission.js +41 -0
  59. package/src/models/GlobalSetting.js +38 -0
  60. package/src/models/HeadlessApiToken.js +24 -0
  61. package/src/models/HeadlessModelDefinition.js +41 -0
  62. package/src/models/I18nEntry.js +77 -0
  63. package/src/models/I18nLocale.js +33 -0
  64. package/src/models/Invite.js +70 -0
  65. package/src/models/JsonConfig.js +46 -0
  66. package/src/models/Notification.js +60 -0
  67. package/src/models/Organization.js +57 -0
  68. package/src/models/OrganizationMember.js +43 -0
  69. package/src/models/StripeCatalogItem.js +77 -0
  70. package/src/models/StripeWebhookEvent.js +57 -0
  71. package/src/models/User.js +89 -0
  72. package/src/models/VirtualEjsFile.js +60 -0
  73. package/src/models/VirtualEjsFileVersion.js +43 -0
  74. package/src/models/VirtualEjsGroupChange.js +32 -0
  75. package/src/models/WaitingList.js +41 -0
  76. package/src/models/Webhook.js +63 -0
  77. package/src/models/Workflow.js +29 -0
  78. package/src/models/WorkflowExecution.js +12 -0
  79. package/src/routes/admin.routes.js +26 -0
  80. package/src/routes/adminAssets.routes.js +28 -0
  81. package/src/routes/adminAssetsStorage.routes.js +13 -0
  82. package/src/routes/adminAudit.routes.js +196 -0
  83. package/src/routes/adminEjsVirtual.routes.js +17 -0
  84. package/src/routes/adminErrors.routes.js +164 -0
  85. package/src/routes/adminFeatureFlags.routes.js +12 -0
  86. package/src/routes/adminHeadless.routes.js +38 -0
  87. package/src/routes/adminI18n.routes.js +22 -0
  88. package/src/routes/adminJsonConfigs.routes.js +15 -0
  89. package/src/routes/adminLlm.routes.js +12 -0
  90. package/src/routes/adminMigration.routes.js +81 -0
  91. package/src/routes/adminSeoConfig.routes.js +20 -0
  92. package/src/routes/adminUploadNamespaces.routes.js +13 -0
  93. package/src/routes/assets.routes.js +21 -0
  94. package/src/routes/auth.routes.js +12 -0
  95. package/src/routes/billing.routes.js +11 -0
  96. package/src/routes/errorTracking.routes.js +31 -0
  97. package/src/routes/featureFlags.routes.js +9 -0
  98. package/src/routes/forms.routes.js +9 -0
  99. package/src/routes/formsAdmin.routes.js +13 -0
  100. package/src/routes/globalSettings.routes.js +18 -0
  101. package/src/routes/headless.routes.js +15 -0
  102. package/src/routes/i18n.routes.js +8 -0
  103. package/src/routes/invite.routes.js +9 -0
  104. package/src/routes/jsonConfigs.routes.js +8 -0
  105. package/src/routes/log.routes.js +111 -0
  106. package/src/routes/metrics.routes.js +9 -0
  107. package/src/routes/notificationAdmin.routes.js +15 -0
  108. package/src/routes/notifications.routes.js +12 -0
  109. package/src/routes/org.routes.js +31 -0
  110. package/src/routes/orgAdmin.routes.js +20 -0
  111. package/src/routes/publicAssets.routes.js +7 -0
  112. package/src/routes/stripeAdmin.routes.js +20 -0
  113. package/src/routes/user.routes.js +22 -0
  114. package/src/routes/userAdmin.routes.js +15 -0
  115. package/src/routes/waitingList.routes.js +13 -0
  116. package/src/routes/waitingListAdmin.routes.js +9 -0
  117. package/src/routes/webhook.routes.js +32 -0
  118. package/src/routes/workflowWebhook.routes.js +54 -0
  119. package/src/routes/workflows.routes.js +110 -0
  120. package/src/services/assets.service.js +110 -0
  121. package/src/services/audit.service.js +62 -0
  122. package/src/services/auditLogger.js +165 -0
  123. package/src/services/ejsVirtual.service.js +614 -0
  124. package/src/services/email.service.js +351 -0
  125. package/src/services/errorLogger.js +221 -0
  126. package/src/services/featureFlags.service.js +202 -0
  127. package/src/services/forms.service.js +214 -0
  128. package/src/services/globalSettings.service.js +49 -0
  129. package/src/services/headlessApiTokens.service.js +158 -0
  130. package/src/services/headlessCrypto.service.js +31 -0
  131. package/src/services/headlessModels.service.js +356 -0
  132. package/src/services/i18n.service.js +314 -0
  133. package/src/services/i18nInferredKeys.service.js +337 -0
  134. package/src/services/jsonConfigs.service.js +392 -0
  135. package/src/services/llm.service.js +749 -0
  136. package/src/services/migration.service.js +581 -0
  137. package/src/services/migrationAssets/fsLocal.js +58 -0
  138. package/src/services/migrationAssets/index.js +134 -0
  139. package/src/services/migrationAssets/s3.js +75 -0
  140. package/src/services/migrationAssets/sftp.js +92 -0
  141. package/src/services/notification.service.js +212 -0
  142. package/src/services/objectStorage.service.js +514 -0
  143. package/src/services/seoConfig.service.js +402 -0
  144. package/src/services/storage.js +150 -0
  145. package/src/services/stripe.service.js +185 -0
  146. package/src/services/stripeHelper.service.js +264 -0
  147. package/src/services/uploadNamespaces.service.js +326 -0
  148. package/src/services/webhook.service.js +157 -0
  149. package/src/services/workflow.service.js +271 -0
  150. package/src/utils/asyncHandler.js +5 -0
  151. package/src/utils/encryption.js +80 -0
  152. package/src/utils/jwt.js +40 -0
  153. package/src/utils/orgRoles.js +156 -0
  154. package/src/utils/validation.js +26 -0
  155. package/src/utils/webhookRetry.js +93 -0
  156. package/views/admin-assets.ejs +444 -0
  157. package/views/admin-audit.ejs +283 -0
  158. package/views/admin-coolify-deploy.ejs +207 -0
  159. package/views/admin-dashboard-home.ejs +291 -0
  160. package/views/admin-dashboard.ejs +397 -0
  161. package/views/admin-ejs-virtual.ejs +280 -0
  162. package/views/admin-errors.ejs +368 -0
  163. package/views/admin-feature-flags.ejs +390 -0
  164. package/views/admin-forms.ejs +526 -0
  165. package/views/admin-global-settings.ejs +436 -0
  166. package/views/admin-headless.ejs +2020 -0
  167. package/views/admin-i18n-locales.ejs +221 -0
  168. package/views/admin-i18n.ejs +728 -0
  169. package/views/admin-json-configs.ejs +410 -0
  170. package/views/admin-llm.ejs +884 -0
  171. package/views/admin-metrics.ejs +274 -0
  172. package/views/admin-migration.ejs +814 -0
  173. package/views/admin-notifications.ejs +430 -0
  174. package/views/admin-organizations.ejs +984 -0
  175. package/views/admin-seo-config.ejs +673 -0
  176. package/views/admin-stripe-pricing.ejs +558 -0
  177. package/views/admin-test.ejs +342 -0
  178. package/views/admin-users.ejs +452 -0
  179. package/views/admin-waiting-list.ejs +547 -0
  180. package/views/admin-webhooks.ejs +329 -0
  181. package/views/admin-workflows.ejs +310 -0
  182. package/views/partials/admin-assets-script.ejs +2022 -0
  183. package/views/partials/admin-test-sidebar.ejs +14 -0
  184. package/views/partials/dashboard/nav-items.ejs +66 -0
  185. package/views/partials/dashboard/palette.ejs +63 -0
  186. package/views/partials/dashboard/sidebar.ejs +21 -0
  187. package/views/partials/dashboard/tab-bar.ejs +26 -0
  188. package/views/partials/footer.ejs +3 -0
@@ -0,0 +1,614 @@
1
+ const crypto = require('crypto');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const ejs = require('ejs');
5
+
6
+ const VirtualEjsFile = require('../models/VirtualEjsFile');
7
+ const VirtualEjsFileVersion = require('../models/VirtualEjsFileVersion');
8
+ const VirtualEjsGroupChange = require('../models/VirtualEjsGroupChange');
9
+
10
+ const llmService = require('./llm.service');
11
+ const { getSettingValue } = require('./globalSettings.service');
12
+ const { createAuditEvent } = require('./audit.service');
13
+
14
+ const CACHE_TTL_MS = 5 * 60 * 1000;
15
+
16
+ const templateCache = new Map();
17
+ let purgeTimerStarted = false;
18
+
19
+ function ensurePurgeTimer() {
20
+ if (purgeTimerStarted) return;
21
+ purgeTimerStarted = true;
22
+
23
+ const timer = setInterval(() => {
24
+ const now = Date.now();
25
+ for (const [k, v] of templateCache.entries()) {
26
+ if (!v || v.expiresAt <= now) {
27
+ templateCache.delete(k);
28
+ }
29
+ }
30
+ }, 60 * 1000);
31
+
32
+ if (typeof timer.unref === 'function') {
33
+ timer.unref();
34
+ }
35
+ }
36
+
37
+ function sha1(text) {
38
+ return crypto.createHash('sha1').update(String(text || ''), 'utf8').digest('hex');
39
+ }
40
+
41
+ function normalizeRelPath(p) {
42
+ const raw = String(p || '').trim();
43
+ if (!raw) {
44
+ const err = new Error('path is required');
45
+ err.code = 'VALIDATION';
46
+ throw err;
47
+ }
48
+
49
+ const posix = raw.replace(/\\/g, '/').replace(/^\/+/, '');
50
+ const normalized = path.posix.normalize(posix);
51
+ if (normalized.startsWith('..')) {
52
+ const err = new Error('Invalid path');
53
+ err.code = 'VALIDATION';
54
+ throw err;
55
+ }
56
+ if (!normalized.endsWith('.ejs')) {
57
+ const err = new Error('path must end with .ejs');
58
+ err.code = 'VALIDATION';
59
+ throw err;
60
+ }
61
+ return normalized;
62
+ }
63
+
64
+ function normalizeViewPath(viewPath) {
65
+ const raw = String(viewPath || '').trim();
66
+ if (!raw) {
67
+ const err = new Error('viewPath is required');
68
+ err.code = 'VALIDATION';
69
+ throw err;
70
+ }
71
+ const p = raw.endsWith('.ejs') ? raw : `${raw}.ejs`;
72
+ return normalizeRelPath(p);
73
+ }
74
+
75
+ function getDefaultViewsRoot() {
76
+ return path.join(process.cwd(), 'src', 'views');
77
+ }
78
+
79
+ function resolveAbsPath(viewsRoot, relPath) {
80
+ const root = path.resolve(String(viewsRoot || getDefaultViewsRoot()));
81
+ const abs = path.resolve(root, relPath);
82
+ if (!abs.startsWith(root + path.sep) && abs !== root) {
83
+ const err = new Error('Invalid view path');
84
+ err.code = 'VALIDATION';
85
+ throw err;
86
+ }
87
+ return abs;
88
+ }
89
+
90
+ async function readFsView(viewsRoot, relPath) {
91
+ const abs = resolveAbsPath(viewsRoot, relPath);
92
+ const stat = await fs.promises.stat(abs);
93
+ if (!stat.isFile()) {
94
+ const err = new Error('View not found on filesystem');
95
+ err.code = 'NOT_FOUND';
96
+ throw err;
97
+ }
98
+ if (stat.size > 500_000) {
99
+ const err = new Error('View file too large');
100
+ err.code = 'VALIDATION';
101
+ throw err;
102
+ }
103
+ return fs.promises.readFile(abs, 'utf8');
104
+ }
105
+
106
+ function cacheKeyFor(relPath, versionKey) {
107
+ return `${relPath}::${versionKey || ''}`;
108
+ }
109
+
110
+ function getCachedTemplate(relPath, versionKey) {
111
+ ensurePurgeTimer();
112
+ const key = cacheKeyFor(relPath, versionKey);
113
+ const v = templateCache.get(key);
114
+ if (!v) return null;
115
+ if (v.expiresAt <= Date.now()) {
116
+ templateCache.delete(key);
117
+ return null;
118
+ }
119
+ return v;
120
+ }
121
+
122
+ function setCachedTemplate(relPath, versionKey, template, meta = {}) {
123
+ ensurePurgeTimer();
124
+ const key = cacheKeyFor(relPath, versionKey);
125
+ templateCache.set(key, {
126
+ template,
127
+ meta,
128
+ expiresAt: Date.now() + CACHE_TTL_MS,
129
+ });
130
+ }
131
+
132
+ function invalidateCacheForPath(relPath) {
133
+ const prefix = `${relPath}::`;
134
+ for (const key of templateCache.keys()) {
135
+ if (key.startsWith(prefix)) {
136
+ templateCache.delete(key);
137
+ }
138
+ }
139
+ }
140
+
141
+ function clearCache() {
142
+ templateCache.clear();
143
+ }
144
+
145
+ async function resolveTemplateSource({ viewsRoot, relPath, allowDb = true }) {
146
+ const normalized = normalizeRelPath(relPath);
147
+
148
+ let fileDoc = null;
149
+ if (allowDb) {
150
+ fileDoc = await VirtualEjsFile.findOne({ path: normalized }).lean();
151
+ }
152
+
153
+ if (fileDoc && fileDoc.enabled === true && typeof fileDoc.content === 'string' && fileDoc.content.trim() !== '') {
154
+ return {
155
+ relPath: normalized,
156
+ source: 'db',
157
+ content: fileDoc.content,
158
+ updatedAt: fileDoc.updatedAt,
159
+ };
160
+ }
161
+
162
+ const fsContent = await readFsView(viewsRoot, normalized);
163
+ return {
164
+ relPath: normalized,
165
+ source: 'fs',
166
+ content: fsContent,
167
+ updatedAt: null,
168
+ };
169
+ }
170
+
171
+ async function recordIntegratedUsage(relPath, actor = null) {
172
+ const normalized = normalizeRelPath(relPath);
173
+
174
+ await VirtualEjsFile.updateOne(
175
+ { path: normalized },
176
+ {
177
+ $set: { integrated: true, lastRenderedAt: new Date() },
178
+ $inc: { renderCount: 1 },
179
+ $setOnInsert: { inferred: true },
180
+ },
181
+ { upsert: true },
182
+ );
183
+
184
+ if (actor) {
185
+ await createAuditEvent({
186
+ ...actor,
187
+ action: 'ejsVirtual.render',
188
+ entityType: 'VirtualEjsFile',
189
+ entityId: normalized,
190
+ before: null,
191
+ after: { path: normalized },
192
+ meta: null,
193
+ });
194
+ }
195
+ }
196
+
197
+ function parseDiffBlocks(content) {
198
+ const blocks = [];
199
+ const regex = /<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/g;
200
+ let match;
201
+
202
+ while ((match = regex.exec(String(content || ''))) !== null) {
203
+ blocks.push({ search: match[1], replace: match[2] });
204
+ }
205
+
206
+ return blocks;
207
+ }
208
+
209
+ function applyDiffs(originalText, diffBlocks) {
210
+ let result = String(originalText || '');
211
+ const appliedRanges = [];
212
+
213
+ for (const block of diffBlocks) {
214
+ const index = result.indexOf(block.search);
215
+ if (index === -1) {
216
+ const err = new Error('Could not find SEARCH block in file');
217
+ err.code = 'DIFF_MATCH_FAILED';
218
+ throw err;
219
+ }
220
+
221
+ const overlaps = appliedRanges.some(([start, end]) =>
222
+ (index >= start && index < end) ||
223
+ (index + block.search.length > start && index + block.search.length <= end),
224
+ );
225
+
226
+ if (overlaps) {
227
+ const err = new Error('Overlapping diff blocks detected');
228
+ err.code = 'DIFF_OVERLAP';
229
+ throw err;
230
+ }
231
+
232
+ result = result.substring(0, index) + block.replace + result.substring(index + block.search.length);
233
+
234
+ appliedRanges.push([index, index + block.replace.length]);
235
+ }
236
+
237
+ return result;
238
+ }
239
+
240
+ function parseMultiFilePatch(text) {
241
+ const raw = String(text || '');
242
+ const lines = raw.split(/\r?\n/);
243
+ const files = [];
244
+
245
+ let current = null;
246
+ for (const line of lines) {
247
+ const m = line.match(/^FILE:\s*(.+)$/);
248
+ if (m) {
249
+ if (current) files.push(current);
250
+ current = { path: m[1].trim(), content: '' };
251
+ continue;
252
+ }
253
+ if (!current) continue;
254
+ current.content += (current.content ? '\n' : '') + line;
255
+ }
256
+ if (current) files.push(current);
257
+
258
+ return files;
259
+ }
260
+
261
+ async function resolveLlmDefaults({ providerKey, model }) {
262
+ const uiProvider = String(providerKey || '').trim();
263
+ const uiModel = String(model || '').trim();
264
+
265
+ const settingProvider = String(await getSettingValue('ejsVirtual.ai.providerKey', '') || '').trim();
266
+ const settingModel = String(await getSettingValue('ejsVirtual.ai.model', '') || '').trim();
267
+
268
+ const envProvider = String(process.env.DEFAULT_LLM_PROVIDER_KEY || '').trim();
269
+ const envModel = String(process.env.DEFAULT_LLM_MODEL || '').trim();
270
+
271
+ const resolvedProviderKey = uiProvider || settingProvider || envProvider;
272
+ if (!resolvedProviderKey) {
273
+ const err = new Error('Missing LLM providerKey (configure ejsVirtual.ai.providerKey or DEFAULT_LLM_PROVIDER_KEY, or send from UI)');
274
+ err.code = 'VALIDATION';
275
+ throw err;
276
+ }
277
+
278
+ const resolvedModel = uiModel || settingModel || envModel || 'x-ai/grok-code-fast-1';
279
+
280
+ return { providerKey: resolvedProviderKey, model: resolvedModel };
281
+ }
282
+
283
+ async function vibeEdit({ prompt, paths, providerKey, model, viewsRoot, actor }) {
284
+ const instruction = String(prompt || '').trim();
285
+ if (!instruction) {
286
+ const err = new Error('prompt is required');
287
+ err.code = 'VALIDATION';
288
+ throw err;
289
+ }
290
+
291
+ const targetPaths = Array.isArray(paths) ? paths : [];
292
+ if (targetPaths.length === 0) {
293
+ const err = new Error('paths is required');
294
+ err.code = 'VALIDATION';
295
+ throw err;
296
+ }
297
+
298
+ const normalizedPaths = targetPaths.map((p) => normalizeRelPath(p));
299
+ const llmDefaults = await resolveLlmDefaults({ providerKey, model });
300
+
301
+ const fileContexts = [];
302
+ for (const relPath of normalizedPaths) {
303
+ const src = await resolveTemplateSource({ viewsRoot, relPath, allowDb: true });
304
+ fileContexts.push({ relPath, content: src.content });
305
+ }
306
+
307
+ const systemPrompt = [
308
+ 'You are a code editor assistant modifying EJS files.',
309
+ 'You may edit multiple files.',
310
+ 'Return ONLY changes using multi-file SEARCH/REPLACE patches.',
311
+ '',
312
+ 'Format:',
313
+ 'FILE: <relative/path.ejs>',
314
+ '<<<<<<< SEARCH',
315
+ '[exact text to find - must match character-by-character including whitespace]',
316
+ '=======',
317
+ '[replacement text]',
318
+ '>>>>>>> REPLACE',
319
+ '',
320
+ 'Rules:',
321
+ '- You can include multiple FILE sections.',
322
+ '- SEARCH must match exactly (whitespace matters).',
323
+ '- Include enough context (5-10 lines) for unique matching.',
324
+ '- Preserve EJS tags and bindings.',
325
+ '- Do not include any text outside FILE sections and SEARCH/REPLACE blocks.',
326
+ ].join('\n');
327
+
328
+ const userContext = fileContexts
329
+ .map((f) => `FILE: ${f.relPath}\n\nCurrent content:\n\n${f.content}`)
330
+ .join('\n\n-----\n\n');
331
+
332
+ const result = await llmService.callAdhoc(
333
+ {
334
+ providerKey: llmDefaults.providerKey,
335
+ model: llmDefaults.model,
336
+ messages: [
337
+ { role: 'system', content: systemPrompt },
338
+ { role: 'user', content: `Instruction:\n${instruction}` },
339
+ { role: 'user', content: userContext },
340
+ ],
341
+ promptKeyForAudit: 'ejsVirtual.vibe',
342
+ },
343
+ { temperature: 0.3 },
344
+ );
345
+
346
+ const raw = String(result.content || '');
347
+ const filePatches = parseMultiFilePatch(raw);
348
+
349
+ if (filePatches.length === 0) {
350
+ const err = new Error('LLM returned no FILE patches');
351
+ err.code = 'AI_INVALID';
352
+ throw err;
353
+ }
354
+
355
+ const patchByPath = new Map();
356
+ for (const fp of filePatches) {
357
+ const p = normalizeRelPath(fp.path);
358
+ patchByPath.set(p, fp.content);
359
+ }
360
+
361
+ const updates = [];
362
+ for (const relPath of normalizedPaths) {
363
+ const patchText = patchByPath.get(relPath);
364
+ if (!patchText) continue;
365
+
366
+ const current = await resolveTemplateSource({ viewsRoot, relPath, allowDb: true });
367
+ const blocks = parseDiffBlocks(patchText);
368
+ if (blocks.length === 0) {
369
+ const err = new Error(`No diff blocks found for ${relPath}`);
370
+ err.code = 'AI_INVALID';
371
+ throw err;
372
+ }
373
+
374
+ const nextContent = applyDiffs(current.content, blocks);
375
+ updates.push({ relPath, before: current.content, after: nextContent });
376
+ }
377
+
378
+ if (updates.length === 0) {
379
+ const err = new Error('No applicable patches matched requested paths');
380
+ err.code = 'AI_INVALID';
381
+ throw err;
382
+ }
383
+
384
+ const groupCount = await VirtualEjsGroupChange.countDocuments({});
385
+ const group = await VirtualEjsGroupChange.create({
386
+ title: `Grouped changes ${groupCount + 1}`,
387
+ summary: instruction.substring(0, 120),
388
+ filePaths: updates.map((u) => u.relPath),
389
+ versionIds: [],
390
+ createdBy: actor?.actorId || null,
391
+ });
392
+
393
+ const versionIds = [];
394
+
395
+ for (const u of updates) {
396
+ const fileDoc = await VirtualEjsFile.findOne({ path: u.relPath });
397
+ const beforeDoc = fileDoc ? fileDoc.toObject() : null;
398
+
399
+ const nextDoc = await VirtualEjsFile.findOneAndUpdate(
400
+ { path: u.relPath },
401
+ {
402
+ $set: {
403
+ path: u.relPath,
404
+ enabled: true,
405
+ content: u.after,
406
+ source: 'llm',
407
+ inferred: true,
408
+ lastSeenAt: new Date(),
409
+ },
410
+ $setOnInsert: {
411
+ integrated: false,
412
+ renderCount: 0,
413
+ lastRenderedAt: null,
414
+ },
415
+ },
416
+ { upsert: true, new: true },
417
+ );
418
+
419
+ const version = await VirtualEjsFileVersion.create({
420
+ fileId: nextDoc._id,
421
+ path: u.relPath,
422
+ content: u.after,
423
+ source: 'llm',
424
+ description: instruction.substring(0, 200),
425
+ groupId: group._id,
426
+ });
427
+
428
+ versionIds.push(version._id);
429
+
430
+ await createAuditEvent({
431
+ ...(actor || { actorType: 'system', actorId: null }),
432
+ action: 'ejsVirtual.vibe.apply',
433
+ entityType: 'VirtualEjsFile',
434
+ entityId: u.relPath,
435
+ before: beforeDoc,
436
+ after: nextDoc.toObject(),
437
+ meta: {
438
+ groupId: String(group._id),
439
+ providerKey: llmDefaults.providerKey,
440
+ model: llmDefaults.model,
441
+ },
442
+ });
443
+
444
+ invalidateCacheForPath(u.relPath);
445
+ }
446
+
447
+ await VirtualEjsGroupChange.updateOne({ _id: group._id }, { $set: { versionIds } });
448
+
449
+ return {
450
+ group: await VirtualEjsGroupChange.findById(group._id).lean(),
451
+ updates: updates.map((u) => ({ path: u.relPath })),
452
+ providerKey: llmDefaults.providerKey,
453
+ model: llmDefaults.model,
454
+ };
455
+ }
456
+
457
+ function extractIncludePaths(ejsSource) {
458
+ const src = String(ejsSource || '');
459
+ const results = new Set();
460
+
461
+ // Matches: <%- include('x') %>, <%= include("x") %>, etc.
462
+ const regex = /include\(\s*['"]([^'"]+)['"]\s*(?:,\s*[^)]*)?\)/g;
463
+ let match;
464
+ while ((match = regex.exec(src)) !== null) {
465
+ const p = String(match[1] || '').trim();
466
+ if (p) results.add(p);
467
+ }
468
+ return Array.from(results);
469
+ }
470
+
471
+ function resolveIncludeRelPath({ viewsRoot, parentAbsPath, includePath }) {
472
+ const baseRoot = path.resolve(String(viewsRoot || getDefaultViewsRoot()));
473
+ let incRaw = String(includePath || '').trim();
474
+ if (!incRaw) return null;
475
+
476
+ // If the incoming include already embeds the views root somewhere (e.g. duplicated path),
477
+ // trim to the portion starting at the views root so we don't end up concatenating it twice.
478
+ const idx = incRaw.indexOf(baseRoot);
479
+ if (idx > 0) {
480
+ incRaw = incRaw.slice(idx);
481
+ }
482
+
483
+ const incWithExt = incRaw.endsWith('.ejs') ? incRaw : `${incRaw}.ejs`;
484
+
485
+ let includeAbs;
486
+ if (path.isAbsolute(incWithExt)) {
487
+ includeAbs = incWithExt;
488
+ } else if (incWithExt.startsWith('/')) {
489
+ includeAbs = resolveAbsPath(baseRoot, incWithExt.replace(/^\//, ''));
490
+ } else {
491
+ includeAbs = path.resolve(path.dirname(parentAbsPath), incWithExt);
492
+ }
493
+
494
+ const normalizedAbs = path.resolve(includeAbs);
495
+ if (!normalizedAbs.startsWith(baseRoot)) {
496
+ const err = new Error('Include path escapes views root');
497
+ err.code = 'VALIDATION';
498
+ throw err;
499
+ }
500
+
501
+ const rel = path.relative(baseRoot, includeAbs).replace(/\\/g, '/');
502
+ return normalizeRelPath(rel);
503
+ }
504
+
505
+ async function preloadTemplatesForRender({ viewsRoot, entryRelPath }) {
506
+ const templatesByRelPath = new Map();
507
+ const absByRelPath = new Map();
508
+ const queue = [entryRelPath];
509
+ const seen = new Set();
510
+
511
+ while (queue.length > 0) {
512
+ const relPath = queue.shift();
513
+ if (!relPath || seen.has(relPath)) continue;
514
+ seen.add(relPath);
515
+
516
+ const src = await resolveTemplateSource({ viewsRoot, relPath, allowDb: true });
517
+ const abs = resolveAbsPath(viewsRoot, relPath);
518
+
519
+ templatesByRelPath.set(relPath, src);
520
+ absByRelPath.set(relPath, abs);
521
+
522
+ const includes = extractIncludePaths(src.content);
523
+ for (const inc of includes) {
524
+ const incRel = resolveIncludeRelPath({ viewsRoot, parentAbsPath: abs, includePath: inc });
525
+ if (incRel) queue.push(incRel);
526
+ }
527
+ }
528
+
529
+ return { templatesByRelPath, absByRelPath };
530
+ }
531
+
532
+ async function renderToString(res, viewPath, data = {}, options = {}) {
533
+ const relPath = normalizeViewPath(viewPath);
534
+ const viewsRoot = path.resolve(options.viewsRoot || (res && res.app ? res.app.get('views') : null) || getDefaultViewsRoot());
535
+
536
+ const { templatesByRelPath, absByRelPath } = await preloadTemplatesForRender({
537
+ viewsRoot,
538
+ entryRelPath: relPath,
539
+ });
540
+
541
+ const entry = templatesByRelPath.get(relPath);
542
+ if (!entry) {
543
+ const err = new Error('Template not found');
544
+ err.code = 'NOT_FOUND';
545
+ throw err;
546
+ }
547
+
548
+ const entryAbs = absByRelPath.get(relPath) || resolveAbsPath(viewsRoot, relPath);
549
+
550
+ const versionKey = entry.source === 'db'
551
+ ? sha1(`${entry.relPath}:${String(entry.updatedAt || '')}`)
552
+ : 'fs';
553
+
554
+ const cached = getCachedTemplate(entry.relPath, versionKey);
555
+ const entryTemplate = cached ? cached.template : entry.content;
556
+ if (!cached) {
557
+ setCachedTemplate(entry.relPath, versionKey, entry.content, {});
558
+ }
559
+
560
+ // Also cache included templates (best-effort)
561
+ for (const [p, src] of templatesByRelPath.entries()) {
562
+ const k = src.source === 'db' ? sha1(`${src.relPath}:${String(src.updatedAt || '')}`) : 'fs';
563
+ if (!getCachedTemplate(p, k)) {
564
+ setCachedTemplate(p, k, src.content, {});
565
+ }
566
+ }
567
+
568
+ function includer(originalPath, parsedPath) {
569
+ const includePath = parsedPath || originalPath;
570
+ const parentAbs = (this && this.filename) ? this.filename : entryAbs;
571
+
572
+ const incRel = resolveIncludeRelPath({
573
+ viewsRoot,
574
+ parentAbsPath: parentAbs,
575
+ includePath,
576
+ });
577
+
578
+ const incAbs = incRel ? absByRelPath.get(incRel) || resolveAbsPath(viewsRoot, incRel) : parentAbs;
579
+ const src = incRel ? templatesByRelPath.get(incRel) : null;
580
+ if (!incRel || !src) {
581
+ return { filename: incAbs };
582
+ }
583
+
584
+ return {
585
+ filename: incAbs,
586
+ template: src.content,
587
+ };
588
+ }
589
+
590
+ await recordIntegratedUsage(relPath, null);
591
+
592
+ return ejs.render(entryTemplate, { ...(data || {}), ...(res?.locals || {}) }, {
593
+ filename: entryAbs,
594
+ async: false,
595
+ includer,
596
+ });
597
+ }
598
+
599
+ async function render(res, viewPath, data = {}, options = {}) {
600
+ const html = await renderToString(res, viewPath, data, options);
601
+ res.send(html);
602
+ }
603
+
604
+ module.exports = {
605
+ normalizeRelPath,
606
+ normalizeViewPath,
607
+ resolveTemplateSource,
608
+ readFsView,
609
+ invalidateCacheForPath,
610
+ clearCache,
611
+ vibeEdit,
612
+ renderToString,
613
+ render,
614
+ };