@env-hopper/backend-core 2.0.1-alpha → 2.0.1-alpha.2

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 (66) hide show
  1. package/dist/index.d.ts +1584 -0
  2. package/dist/index.d.ts.map +1 -0
  3. package/dist/index.js +1806 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +26 -11
  6. package/prisma/migrations/20250526183023_init/migration.sql +71 -0
  7. package/prisma/migrations/migration_lock.toml +3 -0
  8. package/prisma/schema.prisma +121 -0
  9. package/src/db/client.ts +34 -0
  10. package/src/db/index.ts +17 -0
  11. package/src/db/syncAppCatalog.ts +67 -0
  12. package/src/db/tableSyncMagazine.ts +22 -0
  13. package/src/db/tableSyncPrismaAdapter.ts +202 -0
  14. package/src/index.ts +96 -3
  15. package/src/modules/admin/chat/createAdminChatHandler.ts +152 -0
  16. package/src/modules/admin/chat/createDatabaseTools.ts +261 -0
  17. package/src/modules/appCatalog/service.ts +79 -0
  18. package/src/modules/appCatalogAdmin/appCatalogAdminRouter.ts +113 -0
  19. package/src/modules/assets/assetRestController.ts +309 -0
  20. package/src/modules/assets/assetUtils.ts +81 -0
  21. package/src/modules/assets/screenshotRestController.ts +195 -0
  22. package/src/modules/assets/screenshotRouter.ts +116 -0
  23. package/src/modules/assets/syncAssets.ts +261 -0
  24. package/src/modules/auth/auth.ts +51 -0
  25. package/src/modules/auth/authProviders.ts +108 -0
  26. package/src/modules/auth/authRouter.ts +77 -0
  27. package/src/modules/auth/authorizationUtils.ts +114 -0
  28. package/src/modules/auth/registerAuthRoutes.ts +33 -0
  29. package/src/modules/icons/iconRestController.ts +190 -0
  30. package/src/modules/icons/iconRouter.ts +157 -0
  31. package/src/modules/icons/iconService.ts +73 -0
  32. package/src/server/controller.ts +102 -29
  33. package/src/server/ehStaticControllerContract.ts +8 -1
  34. package/src/server/ehTrpcContext.ts +0 -6
  35. package/src/types/backend/api.ts +1 -14
  36. package/src/types/backend/companySpecificBackend.ts +17 -0
  37. package/src/types/common/appCatalogTypes.ts +167 -0
  38. package/src/types/common/dataRootTypes.ts +72 -10
  39. package/src/types/index.ts +2 -0
  40. package/dist/esm/__tests__/dummy.test.d.ts +0 -1
  41. package/dist/esm/index.d.ts +0 -7
  42. package/dist/esm/index.js +0 -9
  43. package/dist/esm/index.js.map +0 -1
  44. package/dist/esm/server/controller.d.ts +0 -32
  45. package/dist/esm/server/controller.js +0 -35
  46. package/dist/esm/server/controller.js.map +0 -1
  47. package/dist/esm/server/db.d.ts +0 -2
  48. package/dist/esm/server/ehStaticControllerContract.d.ts +0 -9
  49. package/dist/esm/server/ehStaticControllerContract.js +0 -12
  50. package/dist/esm/server/ehStaticControllerContract.js.map +0 -1
  51. package/dist/esm/server/ehTrpcContext.d.ts +0 -8
  52. package/dist/esm/server/ehTrpcContext.js +0 -11
  53. package/dist/esm/server/ehTrpcContext.js.map +0 -1
  54. package/dist/esm/types/backend/api.d.ts +0 -71
  55. package/dist/esm/types/backend/common.d.ts +0 -9
  56. package/dist/esm/types/backend/dataSources.d.ts +0 -20
  57. package/dist/esm/types/backend/deployments.d.ts +0 -34
  58. package/dist/esm/types/common/app/appTypes.d.ts +0 -12
  59. package/dist/esm/types/common/app/ui/appUiTypes.d.ts +0 -10
  60. package/dist/esm/types/common/appCatalogTypes.d.ts +0 -16
  61. package/dist/esm/types/common/dataRootTypes.d.ts +0 -32
  62. package/dist/esm/types/common/env/envTypes.d.ts +0 -6
  63. package/dist/esm/types/common/resourceTypes.d.ts +0 -8
  64. package/dist/esm/types/common/sharedTypes.d.ts +0 -4
  65. package/dist/esm/types/index.d.ts +0 -11
  66. package/src/server/db.ts +0 -4
package/dist/index.js ADDED
@@ -0,0 +1,1806 @@
1
+ import { initTRPC } from "@trpc/server";
2
+ import z$1, { z } from "zod";
3
+ import { PrismaClient } from "@prisma/client";
4
+ import { tableSync } from "@env-hopper/table-sync";
5
+ import { mapValues, omit, pick } from "radashi";
6
+ import { createHash } from "node:crypto";
7
+ import sharp from "sharp";
8
+ import { betterAuth } from "better-auth";
9
+ import { prismaAdapter } from "better-auth/adapters/prisma";
10
+ import { toNodeHandler } from "better-auth/node";
11
+ import { genericOAuth, okta } from "better-auth/plugins";
12
+ import { stepCountIs, streamText, tool } from "ai";
13
+ import multer from "multer";
14
+ import { readFileSync, readdirSync } from "node:fs";
15
+ import { extname, join } from "node:path";
16
+
17
+ //#region src/db/client.ts
18
+ let prismaClient = null;
19
+ /**
20
+ * Gets the internal Prisma client instance.
21
+ * Creates one if it doesn't exist.
22
+ */
23
+ function getDbClient() {
24
+ if (!prismaClient) prismaClient = new PrismaClient();
25
+ return prismaClient;
26
+ }
27
+ /**
28
+ * Connects to the database.
29
+ * Call this before performing database operations.
30
+ */
31
+ async function connectDb() {
32
+ await getDbClient().$connect();
33
+ }
34
+ /**
35
+ * Disconnects from the database.
36
+ * Call this when done with database operations (e.g., in scripts).
37
+ */
38
+ async function disconnectDb() {
39
+ if (prismaClient) {
40
+ await prismaClient.$disconnect();
41
+ prismaClient = null;
42
+ }
43
+ }
44
+
45
+ //#endregion
46
+ //#region src/modules/appCatalog/service.ts
47
+ function capitalize(word) {
48
+ if (!word) return word;
49
+ return word.charAt(0).toUpperCase() + word.slice(1);
50
+ }
51
+ async function getAppsFromPrisma() {
52
+ return (await getDbClient().dbAppForCatalog.findMany()).map((row) => {
53
+ const access = row.access;
54
+ const roles = row.roles == null ? void 0 : row.roles;
55
+ const teams = row.teams ?? [];
56
+ const tags = row.tags ?? [];
57
+ const screenshotIds = row.screenshotIds ?? [];
58
+ const notes = row.notes == null ? void 0 : row.notes;
59
+ const appUrl = row.appUrl == null ? void 0 : row.appUrl;
60
+ const iconName = row.iconName == null ? void 0 : row.iconName;
61
+ return {
62
+ id: row.id,
63
+ slug: row.slug,
64
+ displayName: row.displayName,
65
+ description: row.description,
66
+ access,
67
+ teams,
68
+ roles,
69
+ approver: row.approverName && row.approverEmail ? {
70
+ name: row.approverName,
71
+ email: row.approverEmail
72
+ } : void 0,
73
+ notes,
74
+ tags,
75
+ appUrl,
76
+ iconName,
77
+ screenshotIds
78
+ };
79
+ });
80
+ }
81
+ function deriveCategories(apps) {
82
+ const tagSet = /* @__PURE__ */ new Set();
83
+ for (const app of apps) for (const tag of app.tags ?? []) {
84
+ const normalized = tag.trim().toLowerCase();
85
+ if (normalized) tagSet.add(normalized);
86
+ }
87
+ const categories = [{
88
+ id: "all",
89
+ name: "All"
90
+ }];
91
+ for (const tag of Array.from(tagSet).sort()) categories.push({
92
+ id: tag,
93
+ name: capitalize(tag)
94
+ });
95
+ return categories;
96
+ }
97
+ async function getAppCatalogData(getAppsOptional) {
98
+ const apps = getAppsOptional ? await getAppsOptional() : await getAppsFromPrisma();
99
+ return {
100
+ apps,
101
+ categories: deriveCategories(apps)
102
+ };
103
+ }
104
+
105
+ //#endregion
106
+ //#region src/db/tableSyncPrismaAdapter.ts
107
+ function getPrismaModelOperations(prisma, prismaModelName) {
108
+ return prisma[prismaModelName.slice(0, 1).toLowerCase() + prismaModelName.slice(1)];
109
+ }
110
+ function tableSyncPrisma(params) {
111
+ const { prisma, prismaModelName, uniqColumns, where: whereGlobal, upsertOnly } = params;
112
+ const prismOperations = getPrismaModelOperations(prisma, prismaModelName);
113
+ return tableSync({
114
+ id: "id",
115
+ uniqColumns,
116
+ readAll: async () => {
117
+ const findManyArgs = whereGlobal ? { where: whereGlobal } : {};
118
+ return await prismOperations.findMany(findManyArgs);
119
+ },
120
+ writeAll: async (createData, update, deleteIds) => {
121
+ const prismaUniqKey = params.uniqColumns.join("_");
122
+ const relationColumnList = params.relationColumns ?? [];
123
+ return prisma.$transaction(async (tx) => {
124
+ const txOps = getPrismaModelOperations(tx, prismaModelName);
125
+ for (const { data, where } of update) {
126
+ const uniqKeyWhere = Object.keys(where).length > 1 ? { [prismaUniqKey]: where } : where;
127
+ const dataScalar = omit(data, relationColumnList);
128
+ const dataRelations = mapValues(pick(data, relationColumnList), (value) => {
129
+ return { set: value };
130
+ });
131
+ await txOps.update({
132
+ data: {
133
+ ...dataScalar,
134
+ ...dataRelations
135
+ },
136
+ where: { ...uniqKeyWhere }
137
+ });
138
+ }
139
+ if (upsertOnly !== true) await txOps.deleteMany({ where: { id: { in: deleteIds } } });
140
+ const createDataMapped = createData.map((data) => {
141
+ const dataScalar = omit(data, relationColumnList);
142
+ const dataRelations = mapValues(pick(data, relationColumnList), (value) => {
143
+ return { connect: value };
144
+ });
145
+ return {
146
+ ...dataScalar,
147
+ ...dataRelations
148
+ };
149
+ });
150
+ if (createDataMapped.length > 0) {
151
+ const uniqKeysInCreate = /* @__PURE__ */ new Set();
152
+ const duplicateKeys = [];
153
+ for (const data of createDataMapped) {
154
+ const key = params.uniqColumns.map((col) => {
155
+ const value = data[col];
156
+ return value === null || value === void 0 ? "null" : String(value);
157
+ }).join(":");
158
+ if (uniqKeysInCreate.has(key)) duplicateKeys.push(key);
159
+ else uniqKeysInCreate.add(key);
160
+ }
161
+ if (duplicateKeys.length > 0) {
162
+ const uniqColumnsStr = params.uniqColumns.join(", ");
163
+ throw new Error(`Duplicate unique key values found in data to be created. Model: ${prismaModelName}, Unique columns: [${uniqColumnsStr}], Duplicate keys: [${duplicateKeys.join(", ")}]`);
164
+ }
165
+ }
166
+ const results = [];
167
+ if (relationColumnList.length === 0) {
168
+ const batchResult = await txOps.createManyAndReturn({ data: createDataMapped });
169
+ results.push(...batchResult);
170
+ } else for (const dataMappedElement of createDataMapped) {
171
+ const newVar = await txOps.create({ data: dataMappedElement });
172
+ results.push(newVar);
173
+ }
174
+ return results;
175
+ });
176
+ }
177
+ });
178
+ }
179
+
180
+ //#endregion
181
+ //#region src/db/tableSyncMagazine.ts
182
+ const TABLE_SYNC_MAGAZINE = { DbAppForCatalog: {
183
+ prismaModelName: "DbAppForCatalog",
184
+ uniqColumns: ["slug"]
185
+ } };
186
+
187
+ //#endregion
188
+ //#region src/db/syncAppCatalog.ts
189
+ /**
190
+ * Syncs app catalog data to the database using table sync.
191
+ * This will create new apps, update existing ones, and delete any that are no longer in the input.
192
+ *
193
+ * Note: Call connectDb() before and disconnectDb() after if running in a script.
194
+ */
195
+ async function syncAppCatalog(apps) {
196
+ const sync = tableSyncPrisma({
197
+ prisma: getDbClient(),
198
+ ...TABLE_SYNC_MAGAZINE.DbAppForCatalog
199
+ });
200
+ const dbApps = apps.map((app) => {
201
+ var _app$approver, _app$approver2;
202
+ return {
203
+ slug: app.slug || app.displayName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""),
204
+ displayName: app.displayName,
205
+ description: app.description,
206
+ access: app.access,
207
+ teams: app.teams ?? [],
208
+ roles: app.roles ?? null,
209
+ approverName: ((_app$approver = app.approver) === null || _app$approver === void 0 ? void 0 : _app$approver.name) ?? null,
210
+ approverEmail: ((_app$approver2 = app.approver) === null || _app$approver2 === void 0 ? void 0 : _app$approver2.email) ?? null,
211
+ notes: app.notes ?? null,
212
+ tags: app.tags ?? [],
213
+ appUrl: app.appUrl ?? null,
214
+ links: app.links ?? null,
215
+ iconName: app.iconName ?? null,
216
+ screenshotIds: app.screenshotIds ?? []
217
+ };
218
+ });
219
+ const actual = (await sync.sync(dbApps)).getActual();
220
+ return {
221
+ created: actual.length - apps.length + (apps.length - actual.length),
222
+ updated: 0,
223
+ deleted: 0,
224
+ total: actual.length
225
+ };
226
+ }
227
+
228
+ //#endregion
229
+ //#region src/modules/appCatalogAdmin/appCatalogAdminRouter.ts
230
+ const AccessMethodSchema = z.object({ type: z.enum([
231
+ "bot",
232
+ "ticketing",
233
+ "email",
234
+ "self-service",
235
+ "documentation",
236
+ "manual"
237
+ ]) }).passthrough();
238
+ const AppLinkSchema = z.object({
239
+ displayName: z.string().optional(),
240
+ url: z.string().url()
241
+ });
242
+ const AppRoleSchema = z.object({
243
+ id: z.string(),
244
+ name: z.string(),
245
+ description: z.string().optional()
246
+ });
247
+ const ApproverSchema = z.object({
248
+ name: z.string(),
249
+ email: z.string().email()
250
+ });
251
+ const CreateAppForCatalogSchema = z.object({
252
+ slug: z.string().min(1).regex(/^[a-z0-9-]+$/, "Slug must be lowercase alphanumeric with hyphens"),
253
+ displayName: z.string().min(1),
254
+ description: z.string(),
255
+ access: AccessMethodSchema,
256
+ teams: z.array(z.string()).optional(),
257
+ roles: z.array(AppRoleSchema).optional(),
258
+ approver: ApproverSchema.optional(),
259
+ notes: z.string().optional(),
260
+ tags: z.array(z.string()).optional(),
261
+ appUrl: z.string().url().optional(),
262
+ links: z.array(AppLinkSchema).optional(),
263
+ iconName: z.string().optional(),
264
+ screenshotIds: z.array(z.string()).optional()
265
+ });
266
+ const UpdateAppForCatalogSchema = CreateAppForCatalogSchema.partial().extend({ id: z.string() });
267
+ function createAppCatalogAdminRouter(t$1) {
268
+ const router$1 = t$1.router;
269
+ const publicProcedure$1 = t$1.procedure;
270
+ return router$1({
271
+ list: publicProcedure$1.query(async () => {
272
+ return getDbClient().dbAppForCatalog.findMany({ orderBy: { displayName: "asc" } });
273
+ }),
274
+ getById: publicProcedure$1.input(z.object({ id: z.string() })).query(async ({ input }) => {
275
+ return getDbClient().dbAppForCatalog.findUnique({ where: { id: input.id } });
276
+ }),
277
+ create: publicProcedure$1.input(CreateAppForCatalogSchema).mutation(async ({ input }) => {
278
+ const prisma = getDbClient();
279
+ const { approver,...rest } = input;
280
+ return prisma.dbAppForCatalog.create({ data: {
281
+ ...rest,
282
+ approverName: (approver === null || approver === void 0 ? void 0 : approver.name) ?? null,
283
+ approverEmail: (approver === null || approver === void 0 ? void 0 : approver.email) ?? null,
284
+ teams: input.teams ?? [],
285
+ tags: input.tags ?? [],
286
+ screenshotIds: input.screenshotIds ?? []
287
+ } });
288
+ }),
289
+ update: publicProcedure$1.input(UpdateAppForCatalogSchema).mutation(async ({ input }) => {
290
+ const prisma = getDbClient();
291
+ const { id, approver,...rest } = input;
292
+ return prisma.dbAppForCatalog.update({
293
+ where: { id },
294
+ data: {
295
+ ...rest,
296
+ ...approver !== void 0 && {
297
+ approverName: approver.name || null,
298
+ approverEmail: approver.email || null
299
+ }
300
+ }
301
+ });
302
+ }),
303
+ delete: publicProcedure$1.input(z.object({ id: z.string() })).mutation(async ({ input }) => {
304
+ return getDbClient().dbAppForCatalog.delete({ where: { id: input.id } });
305
+ })
306
+ });
307
+ }
308
+
309
+ //#endregion
310
+ //#region src/modules/assets/screenshotRouter.ts
311
+ function createScreenshotRouter(t$1) {
312
+ const router$1 = t$1.router;
313
+ const publicProcedure$1 = t$1.procedure;
314
+ return router$1({
315
+ list: publicProcedure$1.query(async () => {
316
+ return getDbClient().dbAsset.findMany({
317
+ where: { assetType: "screenshot" },
318
+ select: {
319
+ id: true,
320
+ name: true,
321
+ mimeType: true,
322
+ fileSize: true,
323
+ width: true,
324
+ height: true,
325
+ createdAt: true,
326
+ updatedAt: true
327
+ },
328
+ orderBy: { createdAt: "desc" }
329
+ });
330
+ }),
331
+ getOne: publicProcedure$1.input(z.object({ id: z.string() })).query(async ({ input }) => {
332
+ return getDbClient().dbAsset.findFirst({
333
+ where: {
334
+ id: input.id,
335
+ assetType: "screenshot"
336
+ },
337
+ select: {
338
+ id: true,
339
+ name: true,
340
+ mimeType: true,
341
+ fileSize: true,
342
+ width: true,
343
+ height: true,
344
+ createdAt: true,
345
+ updatedAt: true
346
+ }
347
+ });
348
+ }),
349
+ getByAppSlug: publicProcedure$1.input(z.object({ appSlug: z.string() })).query(async ({ input }) => {
350
+ const prisma = getDbClient();
351
+ const app = await prisma.dbAppForCatalog.findUnique({
352
+ where: { slug: input.appSlug },
353
+ select: { screenshotIds: true }
354
+ });
355
+ if (!app) return [];
356
+ return prisma.dbAsset.findMany({
357
+ where: {
358
+ id: { in: app.screenshotIds },
359
+ assetType: "screenshot"
360
+ },
361
+ select: {
362
+ id: true,
363
+ name: true,
364
+ mimeType: true,
365
+ fileSize: true,
366
+ width: true,
367
+ height: true,
368
+ createdAt: true,
369
+ updatedAt: true
370
+ }
371
+ });
372
+ }),
373
+ getFirstByAppSlug: publicProcedure$1.input(z.object({ appSlug: z.string() })).query(async ({ input }) => {
374
+ const prisma = getDbClient();
375
+ const app = await prisma.dbAppForCatalog.findUnique({
376
+ where: { slug: input.appSlug },
377
+ select: { screenshotIds: true }
378
+ });
379
+ if (!app || app.screenshotIds.length === 0) return null;
380
+ return prisma.dbAsset.findUnique({
381
+ where: { id: app.screenshotIds[0] },
382
+ select: {
383
+ id: true,
384
+ name: true,
385
+ mimeType: true,
386
+ fileSize: true,
387
+ width: true,
388
+ height: true,
389
+ createdAt: true,
390
+ updatedAt: true
391
+ }
392
+ });
393
+ })
394
+ });
395
+ }
396
+
397
+ //#endregion
398
+ //#region src/modules/auth/authRouter.ts
399
+ /**
400
+ * Create auth tRPC procedures
401
+ * @param t - tRPC instance
402
+ * @param auth - Better Auth instance (optional, for future extensions)
403
+ * @returns tRPC router with auth procedures
404
+ */
405
+ function createAuthRouter(t$1, auth) {
406
+ const router$1 = t$1.router;
407
+ const publicProcedure$1 = t$1.procedure;
408
+ return router$1({
409
+ getSession: publicProcedure$1.query(async ({ ctx }) => {
410
+ const contextWithUser = ctx;
411
+ return {
412
+ user: contextWithUser.user ?? null,
413
+ isAuthenticated: !!contextWithUser.user
414
+ };
415
+ }),
416
+ getProviders: publicProcedure$1.query(() => {
417
+ const providers = [];
418
+ const authOptions = auth === null || auth === void 0 ? void 0 : auth.options;
419
+ if (authOptions === null || authOptions === void 0 ? void 0 : authOptions.socialProviders) {
420
+ const socialProviders = authOptions.socialProviders;
421
+ Object.keys(socialProviders).forEach((key) => {
422
+ if (socialProviders[key]) providers.push(key);
423
+ });
424
+ }
425
+ if (authOptions === null || authOptions === void 0 ? void 0 : authOptions.plugins) authOptions.plugins.forEach((plugin) => {
426
+ var _pluginWithConfig$opt;
427
+ const pluginWithConfig = plugin;
428
+ if (pluginWithConfig.id === "generic-oauth" && ((_pluginWithConfig$opt = pluginWithConfig.options) === null || _pluginWithConfig$opt === void 0 ? void 0 : _pluginWithConfig$opt.config)) (Array.isArray(pluginWithConfig.options.config) ? pluginWithConfig.options.config : [pluginWithConfig.options.config]).forEach((config) => {
429
+ if (config.providerId) providers.push(config.providerId);
430
+ });
431
+ });
432
+ return { providers };
433
+ })
434
+ });
435
+ }
436
+
437
+ //#endregion
438
+ //#region src/modules/assets/assetUtils.ts
439
+ /**
440
+ * Extract image dimensions from a buffer using sharp
441
+ */
442
+ async function getImageDimensions(buffer) {
443
+ try {
444
+ const metadata = await sharp(buffer).metadata();
445
+ return {
446
+ width: metadata.width ?? null,
447
+ height: metadata.height ?? null
448
+ };
449
+ } catch (error) {
450
+ console.error("Error extracting image dimensions:", error);
451
+ return {
452
+ width: null,
453
+ height: null
454
+ };
455
+ }
456
+ }
457
+ /**
458
+ * Resize an image buffer to the specified dimensions
459
+ * @param buffer - The image buffer to resize
460
+ * @param width - Target width (optional)
461
+ * @param height - Target height (optional)
462
+ * @param format - Output format ('png', 'jpeg', 'webp'), auto-detected if not provided
463
+ */
464
+ async function resizeImage(buffer, width, height, format) {
465
+ let pipeline = sharp(buffer);
466
+ if (width || height) pipeline = pipeline.resize({
467
+ width,
468
+ height,
469
+ fit: "inside",
470
+ withoutEnlargement: true
471
+ });
472
+ if (format === "png") pipeline = pipeline.png();
473
+ else if (format === "webp") pipeline = pipeline.webp();
474
+ else if (format === "jpeg") pipeline = pipeline.jpeg();
475
+ return pipeline.toBuffer();
476
+ }
477
+ /**
478
+ * Generate SHA-256 checksum for a buffer
479
+ */
480
+ function generateChecksum(buffer) {
481
+ return createHash("sha256").update(buffer).digest("hex");
482
+ }
483
+ /**
484
+ * Detect image format from mime type
485
+ */
486
+ function getImageFormat(mimeType) {
487
+ if (mimeType.includes("png")) return "png";
488
+ if (mimeType.includes("webp")) return "webp";
489
+ if (mimeType.includes("jpeg") || mimeType.includes("jpg")) return "jpeg";
490
+ return null;
491
+ }
492
+ /**
493
+ * Check if a mime type represents a raster image (not SVG)
494
+ */
495
+ function isRasterImage(mimeType) {
496
+ return mimeType.startsWith("image/") && !mimeType.includes("svg");
497
+ }
498
+
499
+ //#endregion
500
+ //#region src/modules/icons/iconRouter.ts
501
+ function createIconRouter(t$1) {
502
+ const router$1 = t$1.router;
503
+ const publicProcedure$1 = t$1.procedure;
504
+ return router$1({
505
+ list: publicProcedure$1.query(async () => {
506
+ return getDbClient().dbAsset.findMany({
507
+ where: { assetType: "icon" },
508
+ select: {
509
+ id: true,
510
+ name: true,
511
+ mimeType: true,
512
+ fileSize: true,
513
+ createdAt: true,
514
+ updatedAt: true
515
+ },
516
+ orderBy: { name: "asc" }
517
+ });
518
+ }),
519
+ getOne: publicProcedure$1.input(z.object({ id: z.string() })).query(async ({ input }) => {
520
+ return getDbClient().dbAsset.findFirst({ where: {
521
+ id: input.id,
522
+ assetType: "icon"
523
+ } });
524
+ }),
525
+ create: publicProcedure$1.input(z.object({
526
+ name: z.string().min(1),
527
+ content: z.string(),
528
+ mimeType: z.string(),
529
+ fileSize: z.number().int().positive()
530
+ })).mutation(async ({ input }) => {
531
+ const prisma = getDbClient();
532
+ const buffer = Buffer.from(input.content, "base64");
533
+ const checksum = generateChecksum(buffer);
534
+ const { width, height } = await getImageDimensions(buffer);
535
+ const existing = await prisma.dbAsset.findFirst({ where: {
536
+ checksum,
537
+ assetType: "icon"
538
+ } });
539
+ if (existing) return existing;
540
+ return prisma.dbAsset.create({ data: {
541
+ name: input.name,
542
+ assetType: "icon",
543
+ content: new Uint8Array(buffer),
544
+ checksum,
545
+ mimeType: input.mimeType,
546
+ fileSize: input.fileSize,
547
+ width,
548
+ height
549
+ } });
550
+ }),
551
+ update: publicProcedure$1.input(z.object({
552
+ id: z.string(),
553
+ name: z.string().min(1).optional(),
554
+ content: z.string().optional(),
555
+ mimeType: z.string().optional(),
556
+ fileSize: z.number().int().positive().optional()
557
+ })).mutation(async ({ input }) => {
558
+ const prisma = getDbClient();
559
+ const { id, content,...rest } = input;
560
+ const data = { ...rest };
561
+ if (content) {
562
+ const buffer = Buffer.from(content, "base64");
563
+ data.content = new Uint8Array(buffer);
564
+ data.checksum = generateChecksum(buffer);
565
+ const { width, height } = await getImageDimensions(buffer);
566
+ data.width = width;
567
+ data.height = height;
568
+ }
569
+ return prisma.dbAsset.update({
570
+ where: { id },
571
+ data
572
+ });
573
+ }),
574
+ delete: publicProcedure$1.input(z.object({ id: z.string() })).mutation(async ({ input }) => {
575
+ return getDbClient().dbAsset.delete({ where: { id: input.id } });
576
+ }),
577
+ deleteMany: publicProcedure$1.input(z.object({ ids: z.array(z.string()) })).mutation(async ({ input }) => {
578
+ return getDbClient().dbAsset.deleteMany({ where: {
579
+ id: { in: input.ids },
580
+ assetType: "icon"
581
+ } });
582
+ }),
583
+ getContent: publicProcedure$1.input(z.object({ id: z.string() })).query(async ({ input }) => {
584
+ const asset = await getDbClient().dbAsset.findFirst({
585
+ where: {
586
+ id: input.id,
587
+ assetType: "icon"
588
+ },
589
+ select: {
590
+ content: true,
591
+ mimeType: true
592
+ }
593
+ });
594
+ if (!asset) throw new Error("Icon not found");
595
+ return {
596
+ content: Buffer.from(asset.content).toString("base64"),
597
+ mimeType: asset.mimeType
598
+ };
599
+ })
600
+ });
601
+ }
602
+
603
+ //#endregion
604
+ //#region src/server/controller.ts
605
+ /**
606
+ * Initialization of tRPC backend
607
+ * Should be done only once per backend!
608
+ */
609
+ const t = initTRPC.context().create({ errorFormatter({ error, shape }) {
610
+ var _data;
611
+ console.error("[tRPC Error]", {
612
+ path: (_data = shape.data) === null || _data === void 0 ? void 0 : _data.path,
613
+ code: error.code,
614
+ message: error.message,
615
+ cause: error.cause,
616
+ stack: error.stack
617
+ });
618
+ return shape;
619
+ } });
620
+ /**
621
+ * Export reusable router and procedure helpers
622
+ * that can be used throughout the router
623
+ */
624
+ const router = t.router;
625
+ const publicProcedure = t.procedure;
626
+ /**
627
+ * Create the main tRPC router with optional auth instance
628
+ * @param auth - Optional Better Auth instance for auth-related queries
629
+ */
630
+ function createTrpcRouter(auth) {
631
+ return router({
632
+ bootstrap: publicProcedure.query(async ({ ctx }) => {
633
+ return await ctx.companySpecificBackend.getBootstrapData();
634
+ }),
635
+ availabilityMatrix: publicProcedure.query(async ({ ctx }) => {
636
+ return await ctx.companySpecificBackend.getAvailabilityMatrix();
637
+ }),
638
+ tryFindRenameRule: publicProcedure.input(z$1.object({
639
+ envSlug: z$1.string().optional(),
640
+ resourceSlug: z$1.string().optional()
641
+ })).query(async ({ input, ctx }) => {
642
+ return await ctx.companySpecificBackend.getNameMigrations(input);
643
+ }),
644
+ resourceJumps: publicProcedure.query(async ({ ctx }) => {
645
+ return await ctx.companySpecificBackend.getResourceJumps();
646
+ }),
647
+ resourceJumpsExtended: publicProcedure.query(async ({ ctx }) => {
648
+ return await ctx.companySpecificBackend.getResourceJumpsExtended();
649
+ }),
650
+ resourceJumpBySlugAndEnv: publicProcedure.input(z$1.object({
651
+ jumpResourceSlug: z$1.string(),
652
+ envSlug: z$1.string()
653
+ })).query(async ({ input, ctx }) => {
654
+ return filterSingleResourceJump(await ctx.companySpecificBackend.getResourceJumps(), input.jumpResourceSlug, input.envSlug);
655
+ }),
656
+ appCatalog: publicProcedure.query(async ({ ctx }) => {
657
+ return await getAppCatalogData(ctx.companySpecificBackend.getApps);
658
+ }),
659
+ icon: createIconRouter(t),
660
+ screenshot: createScreenshotRouter(t),
661
+ appCatalogAdmin: createAppCatalogAdminRouter(t),
662
+ auth: createAuthRouter(t, auth)
663
+ });
664
+ }
665
+ function filterSingleResourceJump(resourceJumps, jumpResourceSlug, envSlug) {
666
+ const filteredResourceJump = resourceJumps.resourceJumps.find((item) => item.slug === jumpResourceSlug);
667
+ const filteredEnv = resourceJumps.envs.find((item) => item.slug === envSlug);
668
+ return {
669
+ resourceJumps: filteredResourceJump ? [filteredResourceJump] : [],
670
+ envs: filteredEnv ? [filteredEnv] : [],
671
+ lateResolvableParams: resourceJumps.lateResolvableParams
672
+ };
673
+ }
674
+
675
+ //#endregion
676
+ //#region src/server/ehTrpcContext.ts
677
+ function createEhTrpcContext({ companySpecificBackend }) {
678
+ return { companySpecificBackend };
679
+ }
680
+
681
+ //#endregion
682
+ //#region src/server/ehStaticControllerContract.ts
683
+ const staticControllerContract = { methods: {
684
+ getIcon: {
685
+ method: "get",
686
+ url: "icon/:icon"
687
+ },
688
+ getScreenshot: {
689
+ method: "get",
690
+ url: "screenshot/:id"
691
+ }
692
+ } };
693
+
694
+ //#endregion
695
+ //#region src/modules/auth/auth.ts
696
+ function createAuth(config) {
697
+ const prisma = getDbClient();
698
+ const isProduction = process.env.NODE_ENV === "production";
699
+ return betterAuth({
700
+ appName: config.appName || "EnvHopper",
701
+ baseURL: config.baseURL,
702
+ basePath: "/api/auth",
703
+ secret: config.secret,
704
+ database: prismaAdapter(prisma, { provider: "postgresql" }),
705
+ socialProviders: config.providers || {},
706
+ plugins: config.plugins || [],
707
+ emailAndPassword: { enabled: true },
708
+ session: {
709
+ expiresIn: config.sessionExpiresIn ?? 3600 * 24 * 30,
710
+ updateAge: config.sessionUpdateAge ?? 3600 * 24,
711
+ cookieCache: {
712
+ enabled: true,
713
+ maxAge: 300
714
+ }
715
+ },
716
+ advanced: { useSecureCookies: isProduction }
717
+ });
718
+ }
719
+
720
+ //#endregion
721
+ //#region src/modules/auth/registerAuthRoutes.ts
722
+ /**
723
+ * Register Better Auth routes with Express
724
+ * @param app - Express application instance
725
+ * @param auth - Better Auth instance
726
+ */
727
+ function registerAuthRoutes(app, auth) {
728
+ app.get("/api/auth/session", async (req, res) => {
729
+ try {
730
+ const session = await auth.api.getSession({ headers: req.headers });
731
+ if (session) res.json(session);
732
+ else res.status(401).json({ error: "Not authenticated" });
733
+ } catch (error) {
734
+ console.error("[Auth Session Error]", error);
735
+ res.status(500).json({ error: "Internal server error" });
736
+ }
737
+ });
738
+ const authHandler = toNodeHandler(auth);
739
+ app.all("/api/auth/{*any}", authHandler);
740
+ }
741
+
742
+ //#endregion
743
+ //#region src/modules/auth/authProviders.ts
744
+ function getAuthProvidersFromEnv() {
745
+ const providers = {};
746
+ if (process.env.AUTH_GITHUB_CLIENT_ID && process.env.AUTH_GITHUB_CLIENT_SECRET) providers.github = {
747
+ clientId: process.env.AUTH_GITHUB_CLIENT_ID,
748
+ clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET
749
+ };
750
+ if (process.env.AUTH_GOOGLE_CLIENT_ID && process.env.AUTH_GOOGLE_CLIENT_SECRET) providers.google = {
751
+ clientId: process.env.AUTH_GOOGLE_CLIENT_ID,
752
+ clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET
753
+ };
754
+ return providers;
755
+ }
756
+ /**
757
+ * Get auth plugins from environment variables
758
+ * Currently supports: Okta
759
+ *
760
+ * Example .env:
761
+ * AUTH_OKTA_CLIENT_ID=your_okta_client_id
762
+ * AUTH_OKTA_CLIENT_SECRET=your_okta_client_secret
763
+ * AUTH_OKTA_ISSUER=https://your-org.okta.com/oauth2/ausxb83g4wY1x09ec0h7
764
+ *
765
+ * Note: If you get "User is not assigned to the client application" errors,
766
+ * you need to configure your Okta application to allow all users:
767
+ * 1. In Okta Admin Console, go to Applications → Your App
768
+ * 2. Assignments tab → Assign to Groups → Add "Everyone" group
769
+ * OR
770
+ * 3. Edit the application → In "User consent" section, enable appropriate settings
771
+ *
772
+ * For group-based authorization:
773
+ * 1. Add "groups" scope to your auth server policy rule
774
+ * 2. Create a groups claim in your auth server
775
+ * 3. Groups will be available in the user object after authentication
776
+ */
777
+ function getAuthPluginsFromEnv() {
778
+ const plugins = [];
779
+ const oktaConfig = [];
780
+ if (process.env.AUTH_OKTA_CLIENT_ID && process.env.AUTH_OKTA_CLIENT_SECRET && process.env.AUTH_OKTA_ISSUER) oktaConfig.push(okta({
781
+ clientId: process.env.AUTH_OKTA_CLIENT_ID,
782
+ clientSecret: process.env.AUTH_OKTA_CLIENT_SECRET,
783
+ issuer: process.env.AUTH_OKTA_ISSUER
784
+ }));
785
+ if (oktaConfig.length > 0) plugins.push(genericOAuth({ config: oktaConfig }));
786
+ return plugins;
787
+ }
788
+ /**
789
+ * Validate required auth environment variables
790
+ */
791
+ function validateAuthConfig() {
792
+ const secret = process.env.BETTER_AUTH_SECRET;
793
+ const baseUrl = process.env.BETTER_AUTH_URL;
794
+ if (!secret) console.warn("BETTER_AUTH_SECRET not set. Using development fallback. Set this in production!");
795
+ if (!baseUrl) console.info("BETTER_AUTH_URL not set. Using default http://localhost:3000");
796
+ }
797
+
798
+ //#endregion
799
+ //#region src/modules/auth/authorizationUtils.ts
800
+ /**
801
+ * Extract groups from user object
802
+ * Groups can be stored in different locations depending on the OAuth provider
803
+ */
804
+ function getUserGroups(user) {
805
+ if (!user) return [];
806
+ const groups = user.groups || user.env_hopper_groups || user.oktaGroups || user.roles || [];
807
+ return Array.isArray(groups) ? groups : [];
808
+ }
809
+ /**
810
+ * Check if user is a member of any of the specified groups
811
+ */
812
+ function isMemberOfAnyGroup(user, allowedGroups) {
813
+ const userGroups = getUserGroups(user);
814
+ return allowedGroups.some((group) => userGroups.includes(group));
815
+ }
816
+ /**
817
+ * Check if user is a member of all specified groups
818
+ */
819
+ function isMemberOfAllGroups(user, requiredGroups) {
820
+ const userGroups = getUserGroups(user);
821
+ return requiredGroups.every((group) => userGroups.includes(group));
822
+ }
823
+ /**
824
+ * Get admin group names from environment variables
825
+ * Default: env_hopper_ui_super_admins
826
+ */
827
+ function getAdminGroupsFromEnv() {
828
+ return (process.env.AUTH_ADMIN_GROUPS || "env_hopper_ui_super_admins").split(",").map((g) => g.trim()).filter(Boolean);
829
+ }
830
+ /**
831
+ * Check if user has admin permissions
832
+ */
833
+ function isAdmin(user) {
834
+ return isMemberOfAnyGroup(user, getAdminGroupsFromEnv());
835
+ }
836
+ /**
837
+ * Require admin permissions - throws error if not admin
838
+ */
839
+ function requireAdmin(user) {
840
+ if (!isAdmin(user)) throw new Error("Forbidden: Admin access required");
841
+ }
842
+ /**
843
+ * Require membership in specific groups - throws error if not member
844
+ */
845
+ function requireGroups(user, groups) {
846
+ if (!isMemberOfAnyGroup(user, groups)) throw new Error(`Forbidden: Membership in one of these groups required: ${groups.join(", ")}`);
847
+ }
848
+
849
+ //#endregion
850
+ //#region src/modules/admin/chat/createAdminChatHandler.ts
851
+ function convertToCoreMessages(messages) {
852
+ return messages.map((msg) => {
853
+ var _msg$parts;
854
+ if (msg.content) return {
855
+ role: msg.role,
856
+ content: msg.content
857
+ };
858
+ const textContent = ((_msg$parts = msg.parts) === null || _msg$parts === void 0 ? void 0 : _msg$parts.filter((part) => part.type === "text").map((part) => part.text).join("")) ?? "";
859
+ return {
860
+ role: msg.role,
861
+ content: textContent
862
+ };
863
+ });
864
+ }
865
+ /**
866
+ * Creates an Express handler for the admin chat endpoint.
867
+ *
868
+ * Usage in thin wrappers:
869
+ *
870
+ * ```typescript
871
+ * // With OpenAI
872
+ * import { openai } from '@ai-sdk/openai'
873
+ * app.post('/api/admin/chat', createAdminChatHandler({
874
+ * model: openai('gpt-4o-mini'),
875
+ * }))
876
+ *
877
+ * // With Claude
878
+ * import { anthropic } from '@ai-sdk/anthropic'
879
+ * app.post('/api/admin/chat', createAdminChatHandler({
880
+ * model: anthropic('claude-sonnet-4-20250514'),
881
+ * }))
882
+ * ```
883
+ */
884
+ function createAdminChatHandler(options) {
885
+ const { model, systemPrompt = "You are a helpful admin assistant for the Env Hopper application. Help users manage apps, data sources, and MCP server configurations.", tools = {}, validateConfig } = options;
886
+ return async (req, res) => {
887
+ try {
888
+ if (validateConfig) validateConfig();
889
+ const { messages } = req.body;
890
+ const coreMessages = convertToCoreMessages(messages);
891
+ console.log("[Admin Chat] Received messages:", JSON.stringify(coreMessages, null, 2));
892
+ console.log("[Admin Chat] Available tools:", Object.keys(tools));
893
+ const response = streamText({
894
+ model,
895
+ system: systemPrompt,
896
+ messages: coreMessages,
897
+ tools,
898
+ stopWhen: stepCountIs(5),
899
+ onFinish: (event) => {
900
+ console.log("[Admin Chat] Finished:", {
901
+ finishReason: event.finishReason,
902
+ usage: event.usage,
903
+ hasText: !!event.text,
904
+ textLength: event.text.length
905
+ });
906
+ }
907
+ }).toUIMessageStreamResponse();
908
+ response.headers.forEach((value, key) => {
909
+ res.setHeader(key, value);
910
+ });
911
+ if (response.body) {
912
+ const reader = response.body.getReader();
913
+ const pump = async () => {
914
+ const { done, value } = await reader.read();
915
+ if (done) {
916
+ res.end();
917
+ return;
918
+ }
919
+ res.write(value);
920
+ return pump();
921
+ };
922
+ await pump();
923
+ } else {
924
+ console.error("[Admin Chat] No response body");
925
+ res.status(500).json({ error: "No response from AI model" });
926
+ }
927
+ } catch (error) {
928
+ console.error("[Admin Chat] Error:", error);
929
+ res.status(500).json({ error: "Failed to process chat request" });
930
+ }
931
+ };
932
+ }
933
+
934
+ //#endregion
935
+ //#region src/modules/admin/chat/createDatabaseTools.ts
936
+ /**
937
+ * Creates a DatabaseClient from a Prisma client.
938
+ */
939
+ function createPrismaDatabaseClient(prisma) {
940
+ return {
941
+ query: async (sql) => {
942
+ return await prisma.$queryRawUnsafe(sql);
943
+ },
944
+ execute: async (sql) => {
945
+ return { affectedRows: await prisma.$executeRawUnsafe(sql) };
946
+ },
947
+ getTables: async () => {
948
+ return (await prisma.$queryRawUnsafe(`SELECT tablename FROM pg_tables WHERE schemaname = 'public'`)).map((t$1) => t$1.tablename);
949
+ },
950
+ getColumns: async (tableName) => {
951
+ return (await prisma.$queryRawUnsafe(`SELECT column_name, data_type, is_nullable
952
+ FROM information_schema.columns
953
+ WHERE table_name = '${tableName}' AND table_schema = 'public'`)).map((c) => ({
954
+ name: c.column_name,
955
+ type: c.data_type,
956
+ nullable: c.is_nullable === "YES"
957
+ }));
958
+ }
959
+ };
960
+ }
961
+ const querySchema = z.object({ sql: z.string().describe("The SELECT SQL query to execute") });
962
+ const modifySchema = z.object({
963
+ sql: z.string().describe("The INSERT, UPDATE, or DELETE SQL query to execute"),
964
+ confirmed: z.boolean().describe("Must be true to execute destructive operations")
965
+ });
966
+ const schemaParamsSchema = z.object({ tableName: z.string().optional().describe("Specific table name to get columns for. If not provided, returns list of all tables.") });
967
+ /**
968
+ * Creates a DatabaseClient using the internal backend-core Prisma client.
969
+ * This is a convenience function for apps that don't need to pass their own Prisma client.
970
+ */
971
+ function createInternalDatabaseClient() {
972
+ return createPrismaDatabaseClient(getDbClient());
973
+ }
974
+ /**
975
+ * Creates AI tools for generic database access.
976
+ *
977
+ * The AI uses these internally - users interact via natural language.
978
+ * Results are formatted as tables by the AI based on the system prompt.
979
+ * Uses the internal backend-core Prisma client automatically.
980
+ */
981
+ function createDatabaseTools() {
982
+ const db = createInternalDatabaseClient();
983
+ return {
984
+ queryDatabase: {
985
+ description: `Execute a SELECT query to read data from the database.
986
+ Use this to list, search, or filter records from any table.
987
+ Always use double quotes around table and column names for PostgreSQL (e.g., SELECT * FROM "App").
988
+ Return results will be formatted as a table for the user.`,
989
+ inputSchema: querySchema,
990
+ execute: async ({ sql }) => {
991
+ console.log(`Executing ${sql}`);
992
+ if (!sql.trim().toUpperCase().startsWith("SELECT")) return { error: "Only SELECT queries are allowed with queryDatabase. Use modifyDatabase for changes." };
993
+ try {
994
+ const results = await db.query(sql);
995
+ return {
996
+ success: true,
997
+ rowCount: Array.isArray(results) ? results.length : 0,
998
+ data: results
999
+ };
1000
+ } catch (error) {
1001
+ return {
1002
+ success: false,
1003
+ error: error instanceof Error ? error.message : "Query failed"
1004
+ };
1005
+ }
1006
+ }
1007
+ },
1008
+ modifyDatabase: {
1009
+ description: `Execute an INSERT, UPDATE, or DELETE query to modify data.
1010
+ Use double quotes around table and column names for PostgreSQL.
1011
+ IMPORTANT: Always ask for user confirmation before executing. Set confirmed=true only after user confirms.
1012
+ For UPDATE/DELETE, always include a WHERE clause to avoid affecting all rows.`,
1013
+ inputSchema: modifySchema,
1014
+ execute: async ({ sql, confirmed }) => {
1015
+ if (!confirmed) return {
1016
+ needsConfirmation: true,
1017
+ message: "Please confirm you want to execute this operation.",
1018
+ sql
1019
+ };
1020
+ const normalizedSql = sql.trim().toUpperCase();
1021
+ if (normalizedSql.startsWith("SELECT")) return { error: "Use queryDatabase for SELECT queries." };
1022
+ if ((normalizedSql.startsWith("UPDATE") || normalizedSql.startsWith("DELETE")) && !normalizedSql.includes("WHERE")) return {
1023
+ error: "UPDATE and DELETE queries must include a WHERE clause for safety.",
1024
+ sql
1025
+ };
1026
+ try {
1027
+ const result = await db.execute(sql);
1028
+ return {
1029
+ success: true,
1030
+ affectedRows: result.affectedRows,
1031
+ message: `Operation completed. ${result.affectedRows} row(s) affected.`
1032
+ };
1033
+ } catch (error) {
1034
+ return {
1035
+ success: false,
1036
+ error: error instanceof Error ? error.message : "Operation failed"
1037
+ };
1038
+ }
1039
+ }
1040
+ },
1041
+ getDatabaseSchema: {
1042
+ description: `Get information about database tables and their columns.
1043
+ Use this to understand the database structure before writing queries.
1044
+ Call without tableName to list all tables, or with tableName to get columns for a specific table.`,
1045
+ inputSchema: schemaParamsSchema,
1046
+ execute: async ({ tableName }) => {
1047
+ try {
1048
+ if (tableName) return {
1049
+ success: true,
1050
+ table: tableName,
1051
+ columns: await db.getColumns(tableName)
1052
+ };
1053
+ else return {
1054
+ success: true,
1055
+ tables: await db.getTables()
1056
+ };
1057
+ } catch (error) {
1058
+ return {
1059
+ success: false,
1060
+ error: error instanceof Error ? error.message : "Failed to get schema"
1061
+ };
1062
+ }
1063
+ }
1064
+ }
1065
+ };
1066
+ }
1067
+ /**
1068
+ * Default system prompt for the database admin assistant.
1069
+ * Can be customized or extended.
1070
+ */
1071
+ const DEFAULT_ADMIN_SYSTEM_PROMPT = `You are a helpful database admin assistant. You help users view and manage data in the database.
1072
+
1073
+ IMPORTANT RULES:
1074
+ 1. When showing data, ALWAYS format it as a numbered ASCII table so users can reference rows by number
1075
+ 2. NEVER show raw SQL to users - just describe what you're doing in plain language
1076
+ 3. When users ask to modify data (update, delete, create), ALWAYS confirm before executing
1077
+ 4. For updates, show the current value and ask for confirmation before changing
1078
+ 5. Keep responses concise and focused on the data
1079
+
1080
+ FORMATTING EXAMPLE:
1081
+ When user asks "show me all apps", respond like:
1082
+ "Here are all the apps:
1083
+
1084
+ | # | ID | Slug | Display Name | Icon |
1085
+ |---|----|---------|-----------------| -------|
1086
+ | 1 | 1 | portal | Portal | portal |
1087
+ | 2 | 2 | admin | Admin Dashboard | admin |
1088
+ | 3 | 3 | api | API Service | null |
1089
+
1090
+ Found 3 apps total."
1091
+
1092
+ When user says "update row 2 display name to 'Admin Panel'":
1093
+ 1. First confirm: "I'll update the app 'Admin Dashboard' (ID: 2) to have display name 'Admin Panel'. Proceed?"
1094
+ 2. Only after user confirms, execute the update
1095
+ 3. Then show the updated row
1096
+
1097
+ AVAILABLE TABLES:
1098
+ Use getDatabaseSchema tool to discover tables and their columns.
1099
+ `;
1100
+
1101
+ //#endregion
1102
+ //#region src/modules/icons/iconRestController.ts
1103
+ const upload$1 = multer({
1104
+ storage: multer.memoryStorage(),
1105
+ limits: { fileSize: 10 * 1024 * 1024 },
1106
+ fileFilter: (_req, file, cb) => {
1107
+ if (!file.mimetype.startsWith("image/")) {
1108
+ cb(/* @__PURE__ */ new Error("Only image files are allowed"));
1109
+ return;
1110
+ }
1111
+ cb(null, true);
1112
+ }
1113
+ });
1114
+ /**
1115
+ * Registers REST endpoints for icon upload and retrieval
1116
+ *
1117
+ * Endpoints:
1118
+ * - POST {basePath}/upload - Upload a new icon (multipart/form-data with 'icon' field and 'name' field)
1119
+ * - GET {basePath}/:id - Get icon binary by ID
1120
+ * - GET {basePath}/:id/metadata - Get icon metadata only
1121
+ */
1122
+ function registerIconRestController(router$1, config) {
1123
+ const { basePath } = config;
1124
+ router$1.post(`${basePath}/upload`, upload$1.single("icon"), async (req, res) => {
1125
+ try {
1126
+ if (!req.file) {
1127
+ res.status(400).json({ error: "No file uploaded" });
1128
+ return;
1129
+ }
1130
+ const name = req.body["name"];
1131
+ if (!name) {
1132
+ res.status(400).json({ error: "Name is required" });
1133
+ return;
1134
+ }
1135
+ const prisma = getDbClient();
1136
+ const checksum = createHash("sha256").update(req.file.buffer).digest("hex");
1137
+ const icon = await prisma.dbAsset.create({ data: {
1138
+ name,
1139
+ assetType: "icon",
1140
+ content: new Uint8Array(req.file.buffer),
1141
+ mimeType: req.file.mimetype,
1142
+ fileSize: req.file.size,
1143
+ checksum
1144
+ } });
1145
+ res.status(201).json({
1146
+ id: icon.id,
1147
+ name: icon.name,
1148
+ mimeType: icon.mimeType,
1149
+ fileSize: icon.fileSize,
1150
+ createdAt: icon.createdAt
1151
+ });
1152
+ } catch (error) {
1153
+ console.error("Error uploading icon:", error);
1154
+ res.status(500).json({ error: "Failed to upload icon" });
1155
+ }
1156
+ });
1157
+ router$1.get(`${basePath}/:id`, async (req, res) => {
1158
+ try {
1159
+ const { id } = req.params;
1160
+ const icon = await getDbClient().dbAsset.findUnique({
1161
+ where: { id },
1162
+ select: {
1163
+ content: true,
1164
+ mimeType: true,
1165
+ name: true
1166
+ }
1167
+ });
1168
+ if (!icon) {
1169
+ res.status(404).json({ error: "Icon not found" });
1170
+ return;
1171
+ }
1172
+ res.setHeader("Content-Type", icon.mimeType);
1173
+ res.setHeader("Content-Disposition", `inline; filename="${icon.name}"`);
1174
+ res.setHeader("Cache-Control", "public, max-age=86400");
1175
+ res.send(icon.content);
1176
+ } catch (error) {
1177
+ console.error("Error fetching icon:", error);
1178
+ res.status(500).json({ error: "Failed to fetch icon" });
1179
+ }
1180
+ });
1181
+ router$1.get(`${basePath}/:id/metadata`, async (req, res) => {
1182
+ try {
1183
+ const { id } = req.params;
1184
+ const icon = await getDbClient().dbAsset.findUnique({
1185
+ where: { id },
1186
+ select: {
1187
+ id: true,
1188
+ name: true,
1189
+ mimeType: true,
1190
+ fileSize: true,
1191
+ createdAt: true,
1192
+ updatedAt: true
1193
+ }
1194
+ });
1195
+ if (!icon) {
1196
+ res.status(404).json({ error: "Icon not found" });
1197
+ return;
1198
+ }
1199
+ res.json(icon);
1200
+ } catch (error) {
1201
+ console.error("Error fetching icon metadata:", error);
1202
+ res.status(500).json({ error: "Failed to fetch icon metadata" });
1203
+ }
1204
+ });
1205
+ router$1.get(`${basePath}/by-name/:name`, async (req, res) => {
1206
+ try {
1207
+ const { name } = req.params;
1208
+ const icon = await getDbClient().dbAsset.findUnique({
1209
+ where: { name },
1210
+ select: {
1211
+ content: true,
1212
+ mimeType: true,
1213
+ name: true
1214
+ }
1215
+ });
1216
+ if (!icon) {
1217
+ res.status(404).json({ error: "Icon not found" });
1218
+ return;
1219
+ }
1220
+ res.setHeader("Content-Type", icon.mimeType);
1221
+ res.setHeader("Content-Disposition", `inline; filename="${icon.name}"`);
1222
+ res.setHeader("Cache-Control", "public, max-age=86400");
1223
+ res.send(icon.content);
1224
+ } catch (error) {
1225
+ console.error("Error fetching icon by name:", error);
1226
+ res.status(500).json({ error: "Failed to fetch icon" });
1227
+ }
1228
+ });
1229
+ }
1230
+
1231
+ //#endregion
1232
+ //#region src/modules/icons/iconService.ts
1233
+ /**
1234
+ * Upsert an icon to the database.
1235
+ * If an icon with the same name exists, it will be updated.
1236
+ * Otherwise, a new icon will be created.
1237
+ */
1238
+ async function upsertIcon(input) {
1239
+ const prisma = getDbClient();
1240
+ const checksum = generateChecksum(input.content);
1241
+ const { width, height } = await getImageDimensions(input.content);
1242
+ return prisma.dbAsset.upsert({
1243
+ where: { name: input.name },
1244
+ update: {
1245
+ content: new Uint8Array(input.content),
1246
+ checksum,
1247
+ mimeType: input.mimeType,
1248
+ fileSize: input.fileSize,
1249
+ width,
1250
+ height
1251
+ },
1252
+ create: {
1253
+ name: input.name,
1254
+ assetType: "icon",
1255
+ content: new Uint8Array(input.content),
1256
+ checksum,
1257
+ mimeType: input.mimeType,
1258
+ fileSize: input.fileSize,
1259
+ width,
1260
+ height
1261
+ }
1262
+ });
1263
+ }
1264
+ /**
1265
+ * Upsert multiple icons to the database.
1266
+ * This is more efficient than calling upsertIcon multiple times.
1267
+ */
1268
+ async function upsertIcons(icons) {
1269
+ const results = [];
1270
+ for (const icon of icons) {
1271
+ const result = await upsertIcon(icon);
1272
+ results.push(result);
1273
+ }
1274
+ return results;
1275
+ }
1276
+ /**
1277
+ * Get an asset (icon or screenshot) by name from the database.
1278
+ * Returns the asset content, mimeType, and name if found.
1279
+ */
1280
+ async function getAssetByName(name) {
1281
+ return getDbClient().dbAsset.findUnique({
1282
+ where: { name },
1283
+ select: {
1284
+ content: true,
1285
+ mimeType: true,
1286
+ name: true
1287
+ }
1288
+ });
1289
+ }
1290
+
1291
+ //#endregion
1292
+ //#region src/modules/assets/assetRestController.ts
1293
+ const upload = multer({
1294
+ storage: multer.memoryStorage(),
1295
+ limits: { fileSize: 10 * 1024 * 1024 },
1296
+ fileFilter: (_req, file, cb) => {
1297
+ if (!file.mimetype.startsWith("image/")) {
1298
+ cb(/* @__PURE__ */ new Error("Only image files are allowed"));
1299
+ return;
1300
+ }
1301
+ cb(null, true);
1302
+ }
1303
+ });
1304
+ /**
1305
+ * Registers REST endpoints for universal asset upload and retrieval
1306
+ *
1307
+ * Endpoints:
1308
+ * - POST {basePath}/upload - Upload a new asset (multipart/form-data)
1309
+ * - GET {basePath}/:id - Get asset binary by ID
1310
+ * - GET {basePath}/:id/metadata - Get asset metadata only
1311
+ * - GET {basePath}/by-name/:name - Get asset binary by name
1312
+ */
1313
+ function registerAssetRestController(router$1, config) {
1314
+ const { basePath } = config;
1315
+ router$1.post(`${basePath}/upload`, upload.single("asset"), async (req, res) => {
1316
+ try {
1317
+ if (!req.file) {
1318
+ res.status(400).json({ error: "No file uploaded" });
1319
+ return;
1320
+ }
1321
+ const name = req.body["name"];
1322
+ const assetType = req.body["assetType"] ?? "icon";
1323
+ if (!name) {
1324
+ res.status(400).json({ error: "Name is required" });
1325
+ return;
1326
+ }
1327
+ const prisma = getDbClient();
1328
+ const checksum = generateChecksum(req.file.buffer);
1329
+ const existing = await prisma.dbAsset.findUnique({
1330
+ where: { checksum },
1331
+ select: {
1332
+ id: true,
1333
+ name: true,
1334
+ assetType: true,
1335
+ checksum: true,
1336
+ mimeType: true,
1337
+ fileSize: true,
1338
+ width: true,
1339
+ height: true,
1340
+ createdAt: true
1341
+ }
1342
+ });
1343
+ if (existing) {
1344
+ res.status(200).json(existing);
1345
+ return;
1346
+ }
1347
+ const { width, height } = await getImageDimensions(req.file.buffer);
1348
+ const asset = await prisma.dbAsset.create({ data: {
1349
+ name,
1350
+ checksum,
1351
+ assetType,
1352
+ content: new Uint8Array(req.file.buffer),
1353
+ mimeType: req.file.mimetype,
1354
+ fileSize: req.file.size,
1355
+ width,
1356
+ height
1357
+ } });
1358
+ res.status(201).json({
1359
+ id: asset.id,
1360
+ name: asset.name,
1361
+ assetType: asset.assetType,
1362
+ mimeType: asset.mimeType,
1363
+ fileSize: asset.fileSize,
1364
+ width: asset.width,
1365
+ height: asset.height,
1366
+ createdAt: asset.createdAt
1367
+ });
1368
+ } catch (error) {
1369
+ console.error("Error uploading asset:", error);
1370
+ res.status(500).json({ error: "Failed to upload asset" });
1371
+ }
1372
+ });
1373
+ router$1.get(`${basePath}/:id`, async (req, res) => {
1374
+ try {
1375
+ const { id } = req.params;
1376
+ const asset = await getDbClient().dbAsset.findUnique({
1377
+ where: { id },
1378
+ select: {
1379
+ content: true,
1380
+ mimeType: true,
1381
+ name: true,
1382
+ width: true,
1383
+ height: true
1384
+ }
1385
+ });
1386
+ if (!asset) {
1387
+ res.status(404).json({ error: "Asset not found" });
1388
+ return;
1389
+ }
1390
+ const resizeEnabled = String(process.env.EH_ASSETS_RESIZE_ENABLED || "true") === "true";
1391
+ const wParam = req.query["w"];
1392
+ const width = wParam ? Number.parseInt(wParam, 10) : void 0;
1393
+ let outBuffer = asset.content;
1394
+ let outMime = asset.mimeType;
1395
+ if (resizeEnabled && isRasterImage(asset.mimeType) && !!width && Number.isFinite(width) && width > 0) {
1396
+ const fmt = getImageFormat(asset.mimeType) || "jpeg";
1397
+ const buf = await resizeImage(Buffer.from(asset.content), width, void 0, fmt);
1398
+ outBuffer = new Uint8Array(buf);
1399
+ outMime = `image/${fmt}`;
1400
+ }
1401
+ res.setHeader("Content-Type", outMime);
1402
+ res.setHeader("Content-Disposition", `inline; filename="${asset.name}"`);
1403
+ res.setHeader("Cache-Control", "public, max-age=86400");
1404
+ res.send(outBuffer);
1405
+ } catch (error) {
1406
+ console.error("Error fetching asset:", error);
1407
+ res.status(500).json({ error: "Failed to fetch asset" });
1408
+ }
1409
+ });
1410
+ router$1.get(`${basePath}/:id/metadata`, async (req, res) => {
1411
+ try {
1412
+ const { id } = req.params;
1413
+ const asset = await getDbClient().dbAsset.findUnique({
1414
+ where: { id },
1415
+ select: {
1416
+ id: true,
1417
+ name: true,
1418
+ assetType: true,
1419
+ mimeType: true,
1420
+ fileSize: true,
1421
+ width: true,
1422
+ height: true,
1423
+ createdAt: true,
1424
+ updatedAt: true
1425
+ }
1426
+ });
1427
+ if (!asset) {
1428
+ res.status(404).json({ error: "Asset not found" });
1429
+ return;
1430
+ }
1431
+ res.json(asset);
1432
+ } catch (error) {
1433
+ console.error("Error fetching asset metadata:", error);
1434
+ res.status(500).json({ error: "Failed to fetch asset metadata" });
1435
+ }
1436
+ });
1437
+ router$1.get(`${basePath}/by-name/:name`, async (req, res) => {
1438
+ try {
1439
+ const { name } = req.params;
1440
+ const asset = await getDbClient().dbAsset.findUnique({
1441
+ where: { name },
1442
+ select: {
1443
+ content: true,
1444
+ mimeType: true,
1445
+ name: true,
1446
+ width: true,
1447
+ height: true
1448
+ }
1449
+ });
1450
+ if (!asset) {
1451
+ res.status(404).json({ error: "Asset not found" });
1452
+ return;
1453
+ }
1454
+ const resizeEnabled = String(process.env.EH_ASSETS_RESIZE_ENABLED || "true") === "true";
1455
+ const wParam = req.query["w"];
1456
+ const width = wParam ? Number.parseInt(wParam, 10) : void 0;
1457
+ let outBuffer = asset.content;
1458
+ let outMime = asset.mimeType;
1459
+ const isRaster = asset.mimeType.startsWith("image/") && !asset.mimeType.includes("svg");
1460
+ if (resizeEnabled && isRaster && !!width && Number.isFinite(width) && width > 0) {
1461
+ const fmt = asset.mimeType.includes("png") ? "png" : asset.mimeType.includes("webp") ? "webp" : "jpeg";
1462
+ let buf;
1463
+ const pipeline = sharp(Buffer.from(asset.content)).resize({
1464
+ width,
1465
+ fit: "inside",
1466
+ withoutEnlargement: true
1467
+ });
1468
+ if (fmt === "png") {
1469
+ buf = await pipeline.png().toBuffer();
1470
+ outMime = "image/png";
1471
+ } else if (fmt === "webp") {
1472
+ buf = await pipeline.webp().toBuffer();
1473
+ outMime = "image/webp";
1474
+ } else {
1475
+ buf = await pipeline.jpeg().toBuffer();
1476
+ outMime = "image/jpeg";
1477
+ }
1478
+ outBuffer = new Uint8Array(buf);
1479
+ }
1480
+ res.setHeader("Content-Type", outMime);
1481
+ res.setHeader("Content-Disposition", `inline; filename="${asset.name}"`);
1482
+ res.setHeader("Cache-Control", "public, max-age=86400");
1483
+ res.send(outBuffer);
1484
+ } catch (error) {
1485
+ console.error("Error fetching asset by name:", error);
1486
+ res.status(500).json({ error: "Failed to fetch asset" });
1487
+ }
1488
+ });
1489
+ }
1490
+
1491
+ //#endregion
1492
+ //#region src/modules/assets/screenshotRestController.ts
1493
+ /**
1494
+ * Registers REST endpoints for screenshot retrieval
1495
+ *
1496
+ * Endpoints:
1497
+ * - GET {basePath}/app/:appId - Get all screenshots for an app
1498
+ * - GET {basePath}/:id - Get screenshot binary by ID
1499
+ * - GET {basePath}/:id/metadata - Get screenshot metadata only
1500
+ */
1501
+ function registerScreenshotRestController(router$1, config) {
1502
+ const { basePath } = config;
1503
+ router$1.get(`${basePath}/app/:appSlug`, async (req, res) => {
1504
+ try {
1505
+ const { appSlug } = req.params;
1506
+ const prisma = getDbClient();
1507
+ const app = await prisma.dbAppForCatalog.findUnique({
1508
+ where: { slug: appSlug },
1509
+ select: { screenshotIds: true }
1510
+ });
1511
+ if (!app) {
1512
+ res.status(404).json({ error: "App not found" });
1513
+ return;
1514
+ }
1515
+ const screenshots = await prisma.dbAsset.findMany({
1516
+ where: {
1517
+ id: { in: app.screenshotIds },
1518
+ assetType: "screenshot"
1519
+ },
1520
+ select: {
1521
+ id: true,
1522
+ name: true,
1523
+ mimeType: true,
1524
+ fileSize: true,
1525
+ width: true,
1526
+ height: true,
1527
+ createdAt: true
1528
+ }
1529
+ });
1530
+ res.json(screenshots);
1531
+ } catch (error) {
1532
+ console.error("Error fetching app screenshots:", error);
1533
+ res.status(500).json({ error: "Failed to fetch screenshots" });
1534
+ }
1535
+ });
1536
+ router$1.get(`${basePath}/app/:appSlug/first`, async (req, res) => {
1537
+ try {
1538
+ const { appSlug } = req.params;
1539
+ const prisma = getDbClient();
1540
+ const app = await prisma.dbAppForCatalog.findUnique({
1541
+ where: { slug: appSlug },
1542
+ select: { screenshotIds: true }
1543
+ });
1544
+ if (!app || app.screenshotIds.length === 0) {
1545
+ res.status(404).json({ error: "No screenshots found" });
1546
+ return;
1547
+ }
1548
+ const screenshot = await prisma.dbAsset.findUnique({
1549
+ where: { id: app.screenshotIds[0] },
1550
+ select: {
1551
+ id: true,
1552
+ name: true,
1553
+ mimeType: true,
1554
+ fileSize: true,
1555
+ width: true,
1556
+ height: true,
1557
+ createdAt: true
1558
+ }
1559
+ });
1560
+ if (!screenshot) {
1561
+ res.status(404).json({ error: "Screenshot not found" });
1562
+ return;
1563
+ }
1564
+ res.json(screenshot);
1565
+ } catch (error) {
1566
+ console.error("Error fetching first screenshot:", error);
1567
+ res.status(500).json({ error: "Failed to fetch screenshot" });
1568
+ }
1569
+ });
1570
+ router$1.get(`${basePath}/:id`, async (req, res) => {
1571
+ try {
1572
+ const { id } = req.params;
1573
+ const sizeParam = req.query.size;
1574
+ const targetSize = sizeParam ? parseInt(sizeParam, 10) : void 0;
1575
+ const screenshot = await getDbClient().dbAsset.findUnique({
1576
+ where: { id },
1577
+ select: {
1578
+ content: true,
1579
+ mimeType: true,
1580
+ name: true
1581
+ }
1582
+ });
1583
+ if (!screenshot) {
1584
+ res.status(404).json({ error: "Screenshot not found" });
1585
+ return;
1586
+ }
1587
+ let content = screenshot.content;
1588
+ if (targetSize && targetSize > 0) try {
1589
+ content = await sharp(screenshot.content).resize(targetSize, targetSize, {
1590
+ fit: "inside",
1591
+ withoutEnlargement: true
1592
+ }).toBuffer();
1593
+ } catch (resizeError) {
1594
+ console.error("Error resizing screenshot:", resizeError);
1595
+ }
1596
+ res.setHeader("Content-Type", screenshot.mimeType);
1597
+ res.setHeader("Content-Disposition", `inline; filename="${screenshot.name}"`);
1598
+ res.setHeader("Cache-Control", "public, max-age=86400");
1599
+ res.send(content);
1600
+ } catch (error) {
1601
+ console.error("Error fetching screenshot:", error);
1602
+ res.status(500).json({ error: "Failed to fetch screenshot" });
1603
+ }
1604
+ });
1605
+ router$1.get(`${basePath}/:id/metadata`, async (req, res) => {
1606
+ try {
1607
+ const { id } = req.params;
1608
+ const screenshot = await getDbClient().dbAsset.findUnique({
1609
+ where: { id },
1610
+ select: {
1611
+ id: true,
1612
+ name: true,
1613
+ mimeType: true,
1614
+ fileSize: true,
1615
+ width: true,
1616
+ height: true,
1617
+ createdAt: true,
1618
+ updatedAt: true
1619
+ }
1620
+ });
1621
+ if (!screenshot) {
1622
+ res.status(404).json({ error: "Screenshot not found" });
1623
+ return;
1624
+ }
1625
+ res.json(screenshot);
1626
+ } catch (error) {
1627
+ console.error("Error fetching screenshot metadata:", error);
1628
+ res.status(500).json({ error: "Failed to fetch screenshot metadata" });
1629
+ }
1630
+ });
1631
+ }
1632
+
1633
+ //#endregion
1634
+ //#region src/modules/assets/syncAssets.ts
1635
+ /**
1636
+ * Sync local asset files (icons and screenshots) from directories into the database.
1637
+ *
1638
+ * This function allows consuming applications to sync asset files without directly
1639
+ * exposing the Prisma client. It handles:
1640
+ * - Icon files: Assigned to apps by matching filename to icon name patterns
1641
+ * - Screenshot files: Assigned to apps by matching filename to app ID (format: <app-id>_screenshot_<no>.<ext>)
1642
+ *
1643
+ * @param config Configuration with paths to icon and screenshot directories
1644
+ */
1645
+ async function syncAssets(config) {
1646
+ const prisma = getDbClient();
1647
+ let iconsUpserted = 0;
1648
+ let screenshotsUpserted = 0;
1649
+ if (config.iconsDir) {
1650
+ console.log(`📁 Syncing icons from ${config.iconsDir}...`);
1651
+ iconsUpserted = await syncIconsFromDirectory(prisma, config.iconsDir);
1652
+ console.log(` ✓ Upserted ${iconsUpserted} icons`);
1653
+ }
1654
+ if (config.screenshotsDir) {
1655
+ console.log(`📷 Syncing screenshots from ${config.screenshotsDir}...`);
1656
+ screenshotsUpserted = await syncScreenshotsFromDirectory(prisma, config.screenshotsDir);
1657
+ console.log(` ✓ Upserted ${screenshotsUpserted} screenshots and assigned to apps`);
1658
+ }
1659
+ return {
1660
+ iconsUpserted,
1661
+ screenshotsUpserted
1662
+ };
1663
+ }
1664
+ /**
1665
+ * Sync icon files from a directory
1666
+ */
1667
+ async function syncIconsFromDirectory(prisma, iconsDir) {
1668
+ let count = 0;
1669
+ try {
1670
+ const files = readdirSync(iconsDir);
1671
+ for (const file of files) {
1672
+ const filePath = join(iconsDir, file);
1673
+ const ext = extname(file).toLowerCase().slice(1);
1674
+ if (![
1675
+ "png",
1676
+ "jpg",
1677
+ "jpeg",
1678
+ "gif",
1679
+ "webp",
1680
+ "svg"
1681
+ ].includes(ext)) continue;
1682
+ try {
1683
+ const content = readFileSync(filePath);
1684
+ const buffer = Buffer.from(content);
1685
+ const checksum = generateChecksum(buffer);
1686
+ const iconName = file.replace(/\.[^/.]+$/, "");
1687
+ if (await prisma.dbAsset.findFirst({ where: {
1688
+ checksum,
1689
+ assetType: "icon"
1690
+ } })) continue;
1691
+ let width = null;
1692
+ let height = null;
1693
+ if (!ext.includes("svg")) {
1694
+ const { width: w, height: h } = await getImageDimensions(buffer);
1695
+ width = w;
1696
+ height = h;
1697
+ }
1698
+ const mimeType = {
1699
+ png: "image/png",
1700
+ jpg: "image/jpeg",
1701
+ jpeg: "image/jpeg",
1702
+ gif: "image/gif",
1703
+ webp: "image/webp",
1704
+ svg: "image/svg+xml"
1705
+ }[ext] || "application/octet-stream";
1706
+ await prisma.dbAsset.create({ data: {
1707
+ name: iconName,
1708
+ assetType: "icon",
1709
+ content: new Uint8Array(buffer),
1710
+ checksum,
1711
+ mimeType,
1712
+ fileSize: buffer.length,
1713
+ width,
1714
+ height
1715
+ } });
1716
+ count++;
1717
+ } catch (error) {
1718
+ console.warn(` ⚠ Failed to sync icon ${file}:`, error);
1719
+ }
1720
+ }
1721
+ } catch (error) {
1722
+ console.error(` ❌ Error reading icons directory:`, error);
1723
+ }
1724
+ return count;
1725
+ }
1726
+ /**
1727
+ * Sync screenshot files from a directory and assign to apps
1728
+ */
1729
+ async function syncScreenshotsFromDirectory(prisma, screenshotsDir) {
1730
+ let count = 0;
1731
+ try {
1732
+ const files = readdirSync(screenshotsDir);
1733
+ const screenshotsByApp = /* @__PURE__ */ new Map();
1734
+ for (const file of files) {
1735
+ const match = file.match(/^(.+?)_screenshot_(\d+)\.([^.]+)$/);
1736
+ if (!match || !match[1] || !match[3]) continue;
1737
+ const appId = match[1];
1738
+ const ext = match[3];
1739
+ if (!screenshotsByApp.has(appId)) screenshotsByApp.set(appId, []);
1740
+ screenshotsByApp.get(appId).push({
1741
+ path: join(screenshotsDir, file),
1742
+ ext
1743
+ });
1744
+ }
1745
+ for (const [appId, screenshots] of screenshotsByApp) try {
1746
+ if (!await prisma.dbAppForCatalog.findUnique({
1747
+ where: { slug: appId },
1748
+ select: { id: true }
1749
+ })) {
1750
+ console.warn(` ⚠ App not found: ${appId}`);
1751
+ continue;
1752
+ }
1753
+ for (const screenshot of screenshots) try {
1754
+ const content = readFileSync(screenshot.path);
1755
+ const buffer = Buffer.from(content);
1756
+ const checksum = generateChecksum(buffer);
1757
+ const existing = await prisma.dbAsset.findFirst({ where: {
1758
+ checksum,
1759
+ assetType: "screenshot"
1760
+ } });
1761
+ if (existing) {
1762
+ const existingApp = await prisma.dbAppForCatalog.findUnique({ where: { slug: appId } });
1763
+ if (existingApp && !existingApp.screenshotIds.includes(existing.id)) await prisma.dbAppForCatalog.update({
1764
+ where: { slug: appId },
1765
+ data: { screenshotIds: [...existingApp.screenshotIds, existing.id] }
1766
+ });
1767
+ continue;
1768
+ }
1769
+ const { width, height } = await getImageDimensions(buffer);
1770
+ const mimeType = {
1771
+ png: "image/png",
1772
+ jpg: "image/jpeg",
1773
+ jpeg: "image/jpeg",
1774
+ gif: "image/gif",
1775
+ webp: "image/webp"
1776
+ }[screenshot.ext.toLowerCase()] || "application/octet-stream";
1777
+ const asset = await prisma.dbAsset.create({ data: {
1778
+ name: `${appId}-screenshot-${Date.now()}`,
1779
+ assetType: "screenshot",
1780
+ content: new Uint8Array(buffer),
1781
+ checksum,
1782
+ mimeType,
1783
+ fileSize: buffer.length,
1784
+ width,
1785
+ height
1786
+ } });
1787
+ await prisma.dbAppForCatalog.update({
1788
+ where: { slug: appId },
1789
+ data: { screenshotIds: { push: asset.id } }
1790
+ });
1791
+ count++;
1792
+ } catch (error) {
1793
+ console.warn(` ⚠ Failed to sync screenshot ${screenshot.path}:`, error);
1794
+ }
1795
+ } catch (error) {
1796
+ console.warn(` ⚠ Failed to process app ${appId}:`, error);
1797
+ }
1798
+ } catch (error) {
1799
+ console.error(` ❌ Error reading screenshots directory:`, error);
1800
+ }
1801
+ return count;
1802
+ }
1803
+
1804
+ //#endregion
1805
+ export { DEFAULT_ADMIN_SYSTEM_PROMPT, TABLE_SYNC_MAGAZINE, connectDb, createAdminChatHandler, createAppCatalogAdminRouter, createAuth, createAuthRouter, createDatabaseTools, createEhTrpcContext, createPrismaDatabaseClient, createScreenshotRouter, createTrpcRouter, disconnectDb, getAdminGroupsFromEnv, getAssetByName, getAuthPluginsFromEnv, getAuthProvidersFromEnv, getDbClient, getUserGroups, isAdmin, isMemberOfAllGroups, isMemberOfAnyGroup, registerAssetRestController, registerAuthRoutes, registerIconRestController, registerScreenshotRestController, requireAdmin, requireGroups, staticControllerContract, syncAppCatalog, syncAssets, tableSyncPrisma, tool, upsertIcon, upsertIcons, validateAuthConfig };
1806
+ //# sourceMappingURL=index.js.map