@oussema_mili/test-pkg-123 1.1.32

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 (49) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +220 -0
  3. package/auth-callback.html +97 -0
  4. package/auth.js +276 -0
  5. package/cli-commands.js +1921 -0
  6. package/containerManager.js +304 -0
  7. package/daemon/agentRunner.js +491 -0
  8. package/daemon/daemonEntry.js +64 -0
  9. package/daemon/daemonManager.js +266 -0
  10. package/daemon/logManager.js +227 -0
  11. package/dist/styles.css +504 -0
  12. package/docker-actions/apps.js +3913 -0
  13. package/docker-actions/config-transformer.js +380 -0
  14. package/docker-actions/containers.js +355 -0
  15. package/docker-actions/general.js +171 -0
  16. package/docker-actions/images.js +1128 -0
  17. package/docker-actions/logs.js +224 -0
  18. package/docker-actions/metrics.js +270 -0
  19. package/docker-actions/registry.js +1100 -0
  20. package/docker-actions/setup-tasks.js +859 -0
  21. package/docker-actions/terminal.js +247 -0
  22. package/docker-actions/volumes.js +713 -0
  23. package/helper-functions.js +193 -0
  24. package/index.html +83 -0
  25. package/index.js +341 -0
  26. package/package.json +82 -0
  27. package/postcss.config.mjs +5 -0
  28. package/scripts/release.sh +212 -0
  29. package/setup/setupWizard.js +403 -0
  30. package/store/agentSessionStore.js +51 -0
  31. package/store/agentStore.js +113 -0
  32. package/store/configStore.js +171 -0
  33. package/store/daemonStore.js +217 -0
  34. package/store/deviceCredentialStore.js +107 -0
  35. package/store/npmTokenStore.js +65 -0
  36. package/store/registryStore.js +329 -0
  37. package/store/setupState.js +147 -0
  38. package/styles.css +1 -0
  39. package/utils/appLogger.js +223 -0
  40. package/utils/deviceInfo.js +98 -0
  41. package/utils/ecrAuth.js +225 -0
  42. package/utils/encryption.js +112 -0
  43. package/utils/envSetup.js +41 -0
  44. package/utils/errorHandler.js +327 -0
  45. package/utils/portUtils.js +59 -0
  46. package/utils/prerequisites.js +323 -0
  47. package/utils/prompts.js +318 -0
  48. package/utils/ssl-certificates.js +256 -0
  49. package/websocket-server.js +415 -0
@@ -0,0 +1,3913 @@
1
+ import { docker } from "./containers.js";
2
+ import fs from "fs";
3
+ import path, { dirname } from "path";
4
+ import os from "os";
5
+ import yaml from "js-yaml";
6
+ import { exec, spawn } from "child_process";
7
+ import axios from "axios";
8
+ import chalk from "chalk";
9
+ import { loadSession } from "../auth.js";
10
+ import { loadConfig } from "../store/configStore.js";
11
+ import { fileURLToPath } from "url";
12
+ import {
13
+ createAppLogger,
14
+ getRecentLogs,
15
+ readLogFile,
16
+ } from "../utils/appLogger.js";
17
+ import {
18
+ toDockerComposeConfig,
19
+ generateHostEnvScript,
20
+ generateEnvFile,
21
+ generateComposeDeploy,
22
+ } from "./config-transformer.js";
23
+ import setupTasks from "./setup-tasks.js";
24
+
25
+ // ES Module equivalent of __dirname
26
+ const __filename = fileURLToPath(import.meta.url);
27
+ const __dirname = dirname(__filename);
28
+
29
+ // Helper to get current backend URL (loaded fresh each time to pick up config changes)
30
+ function getBackendUrl() {
31
+ const config = loadConfig();
32
+ return config.backendUrl;
33
+ }
34
+
35
+ // Fenwave workspace configuration paths
36
+ const FENWAVE_CONFIG_DIR = path.join(os.homedir(), ".fenwave", "config");
37
+ const WORKSPACE_REGISTRY_PATH = path.join(
38
+ FENWAVE_CONFIG_DIR,
39
+ "workspaces.json",
40
+ );
41
+
42
+ /**
43
+ * Workspace metadata management
44
+ */
45
+
46
+ // Ensure Fenwave config directory exists
47
+ function ensureFenwaveConfigDir() {
48
+ if (!fs.existsSync(FENWAVE_CONFIG_DIR)) {
49
+ fs.mkdirSync(FENWAVE_CONFIG_DIR, { recursive: true });
50
+ }
51
+ }
52
+
53
+ // Load workspace registry (maps appId -> workspace path)
54
+ function loadWorkspaceRegistry() {
55
+ ensureFenwaveConfigDir();
56
+ if (!fs.existsSync(WORKSPACE_REGISTRY_PATH)) {
57
+ return {};
58
+ }
59
+ try {
60
+ const data = fs.readFileSync(WORKSPACE_REGISTRY_PATH, "utf8");
61
+ return JSON.parse(data);
62
+ } catch (error) {
63
+ console.warn("⚠️ Failed to load workspace registry:", error.message);
64
+ return {};
65
+ }
66
+ }
67
+
68
+ // Save workspace registry
69
+ function saveWorkspaceRegistry(registry) {
70
+ ensureFenwaveConfigDir();
71
+ try {
72
+ fs.writeFileSync(
73
+ WORKSPACE_REGISTRY_PATH,
74
+ JSON.stringify(registry, null, 2),
75
+ "utf8",
76
+ );
77
+ } catch (error) {
78
+ console.error("❌ Failed to save workspace registry:", error.message);
79
+ }
80
+ }
81
+
82
+ // Register a workspace for an app version
83
+ function registerWorkspace(appId, appName, appVersion, workspacePath) {
84
+ const registry = loadWorkspaceRegistry();
85
+ const registryKey = `${appId}-${appVersion}`;
86
+ registry[registryKey] = {
87
+ appId,
88
+ appName,
89
+ appVersion,
90
+ workspacePath,
91
+ registeredAt: new Date().toISOString(),
92
+ };
93
+ saveWorkspaceRegistry(registry);
94
+ }
95
+
96
+ // Unregister a workspace for an app version
97
+ function unregisterWorkspace(appId, appVersion) {
98
+ const registry = loadWorkspaceRegistry();
99
+ const registryKey = `${appId}-${appVersion}`;
100
+ if (registry[registryKey]) {
101
+ delete registry[registryKey];
102
+ saveWorkspaceRegistry(registry);
103
+ return true;
104
+ }
105
+ return false;
106
+ }
107
+
108
+ // Get registered workspace path for an app version
109
+ function getRegisteredWorkspace(appId, appVersion) {
110
+ const registry = loadWorkspaceRegistry();
111
+ const registryKey = `${appId}-${appVersion}`;
112
+ return registry[registryKey] || null;
113
+ }
114
+
115
+ // Delete workspace folder recursively
116
+ function deleteWorkspaceFolder(workspacePath) {
117
+ if (workspacePath && fs.existsSync(workspacePath)) {
118
+ try {
119
+ fs.rmSync(workspacePath, { recursive: true, force: true });
120
+ console.log(`🗑️ Deleted workspace folder: ${workspacePath}`);
121
+ return true;
122
+ } catch (error) {
123
+ console.warn(`⚠️ Failed to delete workspace folder: ${error.message}`);
124
+ return false;
125
+ }
126
+ }
127
+ return false;
128
+ }
129
+
130
+ // Create workspace metadata file
131
+ function createWorkspaceMetadata(workspacePath, appId, appName) {
132
+ const fenwaveDir = path.join(workspacePath, ".fenwave");
133
+ if (!fs.existsSync(fenwaveDir)) {
134
+ fs.mkdirSync(fenwaveDir, { recursive: true });
135
+ }
136
+
137
+ const metadata = {
138
+ appId,
139
+ appName,
140
+ createdAt: new Date().toISOString(),
141
+ lastSyncedAt: new Date().toISOString(),
142
+ };
143
+
144
+ const metadataPath = path.join(fenwaveDir, "workspace.json");
145
+ fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), "utf8");
146
+ }
147
+
148
+ // Update workspace metadata (e.g., after sync)
149
+ function updateWorkspaceMetadata(workspacePath) {
150
+ const metadataPath = path.join(workspacePath, ".fenwave", "workspace.json");
151
+
152
+ if (!fs.existsSync(metadataPath)) {
153
+ return;
154
+ }
155
+
156
+ try {
157
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf8"));
158
+ metadata.lastSyncedAt = new Date().toISOString();
159
+ fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), "utf8");
160
+ } catch (error) {
161
+ console.warn("⚠️ Failed to update workspace metadata:", error.message);
162
+ }
163
+ }
164
+
165
+ // Verify workspace is valid (has .fenwave/workspace.json)
166
+ function isValidWorkspace(workspacePath) {
167
+ const metadataPath = path.join(workspacePath, ".fenwave", "workspace.json");
168
+ return fs.existsSync(metadataPath);
169
+ }
170
+
171
+ // Resolve workspace path for each app version
172
+ function resolveWorkspacePath(appId, appName, appVersion, context = "default") {
173
+ // Check if workspace is registered
174
+ const registered = getRegisteredWorkspace(appId, appVersion);
175
+
176
+ if (registered) {
177
+ // Verify the registered path is still valid
178
+ if (isValidWorkspace(registered.workspacePath)) {
179
+ return registered.workspacePath;
180
+ } else {
181
+ if (context === "delete") {
182
+ console.warn(
183
+ `⚠️ Registered workspace not found at ${registered.workspacePath}, skipping workspace deletion...`,
184
+ );
185
+ } else if (context !== "sync") {
186
+ console.warn(
187
+ `⚠️ Registered workspace not found at ${registered.workspacePath}, will recreate...`,
188
+ );
189
+ }
190
+ }
191
+ }
192
+
193
+ // Create new workspace in ~/.fenwave/docker/
194
+ const fenwaveDockerDir = path.join(os.homedir(), ".fenwave", "docker");
195
+ const normalizedAppName = appName.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
196
+ const newWorkspacePath = path.join(
197
+ fenwaveDockerDir,
198
+ `${normalizedAppName}-${appVersion}`,
199
+ );
200
+
201
+ return newWorkspacePath;
202
+ }
203
+
204
+ // Resolve workspace path for draft apps (no version suffix)
205
+ function resolveWorkspacePathForDraft(appId, appName, context = "default") {
206
+ // Check if workspace is registered for draft (using "draft" as version key)
207
+ const registered = getRegisteredWorkspace(appId, "draft");
208
+
209
+ if (registered) {
210
+ // Verify the registered path is still valid
211
+ if (isValidWorkspace(registered.workspacePath)) {
212
+ return registered.workspacePath;
213
+ } else {
214
+ if (context === "delete") {
215
+ console.warn(
216
+ `⚠️ Registered workspace not found at ${registered.workspacePath}, skipping workspace deletion...`,
217
+ );
218
+ } else if (context !== "sync") {
219
+ console.warn(
220
+ `⚠️ Registered workspace not found at ${registered.workspacePath}, will recreate...`,
221
+ );
222
+ }
223
+ }
224
+ }
225
+
226
+ // Create new workspace in ~/.fenwave/docker/ without version suffix
227
+ const fenwaveDockerDir = path.join(os.homedir(), ".fenwave", "docker");
228
+ const normalizedAppName = appName.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
229
+ const newWorkspacePath = path.join(fenwaveDockerDir, normalizedAppName);
230
+
231
+ return newWorkspacePath;
232
+ }
233
+
234
+ // Backup existing docker-compose.yml before sync
235
+ function backupDockerCompose(workspacePath) {
236
+ const dockerComposePath = path.join(workspacePath, "docker-compose.yml");
237
+ const backupPath = path.join(workspacePath, "docker-compose.backup.yml");
238
+
239
+ if (fs.existsSync(dockerComposePath)) {
240
+ try {
241
+ fs.copyFileSync(dockerComposePath, backupPath);
242
+ console.log(`📦 Backed up existing docker-compose.yml`);
243
+ return true;
244
+ } catch (error) {
245
+ console.warn("⚠️ Failed to backup docker-compose.yml:", error.message);
246
+ return false;
247
+ }
248
+ }
249
+ return false;
250
+ }
251
+
252
+ /**
253
+ * Filter containers by app identity using fwId labels
254
+ * This is the centralized function for matching containers to a specific app version
255
+ * @param {Array} containers - List of Docker containers
256
+ * @param {string} fwId - The Fenwave entity ID
257
+ * @param {boolean} isDraft - Whether the app is a draft
258
+ * @param {string} appVersion - The app version (only used for published apps)
259
+ * @returns {Array} - Filtered containers belonging to this specific app
260
+ */
261
+ function filterContainersByApp(containers, fwId, isDraft, appVersion) {
262
+ return containers.filter((container) => {
263
+ const containerFwId = container.Labels["app.fwId"];
264
+ const containerIsDraft = container.Labels["app.isDraft"];
265
+ const containerVersion = container.Labels["app.version"];
266
+
267
+ // Match by fwId and draft/version status
268
+ if (containerFwId === fwId) {
269
+ if (isDraft) return containerIsDraft === "true";
270
+ return containerIsDraft !== "true" && containerVersion === appVersion;
271
+ }
272
+
273
+ return false;
274
+ });
275
+ }
276
+
277
+ /**
278
+ * Filter containers for any version of a published app (ignores version label)
279
+ * @param {Array} containers - List of Docker containers
280
+ * @param {string} fwId - The Fenwave entity ID
281
+ * @returns {Array} - Filtered containers belonging to this app (any version)
282
+ */
283
+ function filterContainersAnyVersion(containers, fwId) {
284
+ return containers.filter((container) => {
285
+ const containerFwId = container.Labels["app.fwId"];
286
+ const containerIsDraft = container.Labels["app.isDraft"];
287
+ return containerFwId === fwId && containerIsDraft !== "true";
288
+ });
289
+ }
290
+
291
+ /**
292
+ * Select the most relevant container from a list deterministically
293
+ * Prioritizes: 1) running containers, 2) most recently started, 3) newest created
294
+ * @param {Array} containers - List of Docker containers
295
+ * @returns {object|null} - The selected container or null if array is empty
296
+ */
297
+ function selectMostRelevantContainer(containers) {
298
+ if (!containers || containers.length === 0) return null;
299
+ if (containers.length === 1) return containers[0];
300
+
301
+ // Prioritize running containers
302
+ const runningContainers = containers.filter((c) => c.State === "running");
303
+ const candidatePool =
304
+ runningContainers.length > 0 ? runningContainers : containers;
305
+
306
+ // Sort by Created timestamp descending (newest first)
307
+ const sortedByCreated = [...candidatePool].sort(
308
+ (a, b) => b.Created - a.Created,
309
+ );
310
+
311
+ return sortedByCreated[0];
312
+ }
313
+
314
+ /**
315
+ * Substitute setup wizard variables in an object (deep replacement)
316
+ * Replaces {{VARIABLE_NAME}} patterns with values from the variables object
317
+ * @param {any} obj - Object to substitute variables in
318
+ * @param {Record<string, string>} variables - Variables to substitute
319
+ * @returns {any} - Object with variables substituted
320
+ */
321
+ function substituteSetupVariables(obj, variables) {
322
+ if (!variables || Object.keys(variables).length === 0) {
323
+ return obj;
324
+ }
325
+
326
+ const substitute = (value) => {
327
+ if (typeof value === "string") {
328
+ // Replace {{VARIABLE_NAME}} patterns
329
+ return value.replace(/\{\{([^}]+)\}\}/g, (match, varName) => {
330
+ const trimmedName = varName.trim();
331
+ if (variables[trimmedName] !== undefined) {
332
+ return variables[trimmedName];
333
+ }
334
+ // Return original if variable not found
335
+ return match;
336
+ });
337
+ }
338
+ if (Array.isArray(value)) {
339
+ return value.map(substitute);
340
+ }
341
+ if (value && typeof value === "object") {
342
+ const result = {};
343
+ for (const key of Object.keys(value)) {
344
+ result[key] = substitute(value[key]);
345
+ }
346
+ return result;
347
+ }
348
+ return value;
349
+ };
350
+
351
+ return substitute(obj);
352
+ }
353
+
354
+ /**
355
+ * Check if an app has ever been run locally (has containers created)
356
+ * @param {string} appName - The app name to check
357
+ * @returns {Promise<boolean>} - True if the app has been run at least once
358
+ */
359
+ async function checkAppHasBeenRun(appName) {
360
+ if (!appName) {
361
+ return false;
362
+ }
363
+
364
+ try {
365
+ // Normalize app name to match Docker container naming convention
366
+ const normalizedAppName = appName
367
+ .replace(/[^a-z0-9-]/gi, "-")
368
+ .toLowerCase();
369
+
370
+ const containers = await docker.listContainers({ all: true });
371
+ const appContainers = containers.filter((container) => {
372
+ const containerName = container.Names[0].replace(/^\//, "");
373
+ return (
374
+ containerName.startsWith(normalizedAppName) ||
375
+ container.Labels["com.docker.compose.project"] === normalizedAppName ||
376
+ container.Labels["app"] === normalizedAppName
377
+ );
378
+ });
379
+
380
+ return appContainers.length > 0;
381
+ } catch (error) {
382
+ // If we can't check containers, assume the app has been run
383
+ // to avoid missing important update notifications
384
+ console.warn(
385
+ `⚠️ Failed to check containers for app ${appName}: ${error.message}`,
386
+ );
387
+ return true;
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Acknowledge an event after processing (e.g., after syncing)
393
+ */
394
+ async function acknowledgeEvent(eventId, changeId) {
395
+ try {
396
+ const session = loadSession();
397
+ if (!session || !session.token) {
398
+ console.warn("⚠️ No session token available to acknowledge event");
399
+ return false;
400
+ }
401
+
402
+ const backendUrl = getBackendUrl();
403
+
404
+ const response = await axios.post(
405
+ `${backendUrl}/api/agent-cli/acknowledge-event`,
406
+ {
407
+ token: session.token,
408
+ eventId,
409
+ changeId,
410
+ },
411
+ {
412
+ headers: {
413
+ "Content-Type": "application/json",
414
+ },
415
+ timeout: 5000,
416
+ },
417
+ );
418
+
419
+ if (response.data && response.data.success) {
420
+ return true;
421
+ }
422
+
423
+ return false;
424
+ } catch (error) {
425
+ console.warn(`⚠️ Failed to acknowledge event: ${error.message}`);
426
+ return false;
427
+ }
428
+ }
429
+
430
+ async function handleAppAction(ws, action, payload) {
431
+ switch (action) {
432
+ case "fetchApps":
433
+ return await handleFetchApps(ws, payload);
434
+ case "fetchAppVersions":
435
+ return await handleFetchAppVersions(ws, payload);
436
+ case "startApp":
437
+ return await handleStartApp(ws, payload);
438
+ case "stopApp":
439
+ return await handleStopApp(ws, payload);
440
+ case "restartApp":
441
+ return await handleRestartApp(ws, payload);
442
+ case "deleteApp":
443
+ return await handleDeleteApp(ws, payload);
444
+ case "cleanApp":
445
+ return await handleCleanApp(ws, payload);
446
+ case "deleteAppVersions":
447
+ return await handleDeleteAppVersions(ws, payload);
448
+ case "createApp":
449
+ return await handleCreateApp(ws, payload);
450
+ case "validateCompose":
451
+ return await handleValidateCompose(ws, payload);
452
+ case "syncApp":
453
+ return await handleSyncApp(ws, payload);
454
+ case "changeVersion":
455
+ return await handleChangeVersion(ws, payload);
456
+ case "fetchAppLogs":
457
+ return await handleFetchAppLogs(ws, payload);
458
+ case "fetchLogContent":
459
+ return await handleFetchLogContent(ws, payload);
460
+ default:
461
+ throw new Error(`Unknown app action: ${action}`);
462
+ }
463
+ }
464
+
465
+ // Fetch recent logs for an app (or all apps)
466
+ async function handleFetchAppLogs(ws, payload) {
467
+ const { appName, limit = 20, requestId } = payload;
468
+
469
+ try {
470
+ const logs = getRecentLogs(appName, limit);
471
+
472
+ ws.send(
473
+ JSON.stringify({
474
+ type: "appLogs",
475
+ logs: logs.map((log) => ({
476
+ filename: log.filename,
477
+ createdAt: log.createdAt.toISOString(),
478
+ size: log.size,
479
+ })),
480
+ requestId,
481
+ }),
482
+ );
483
+ } catch (error) {
484
+ ws.send(
485
+ JSON.stringify({
486
+ type: "error",
487
+ error: `Failed to fetch logs: ${error.message}`,
488
+ requestId,
489
+ }),
490
+ );
491
+ }
492
+ }
493
+
494
+ // Fetch content of a specific log file
495
+ async function handleFetchLogContent(ws, payload) {
496
+ const { filename, requestId } = payload;
497
+
498
+ try {
499
+ const content = readLogFile(filename);
500
+
501
+ if (content === null) {
502
+ ws.send(
503
+ JSON.stringify({
504
+ type: "error",
505
+ error: `Log file not found: ${filename}`,
506
+ requestId,
507
+ }),
508
+ );
509
+ return;
510
+ }
511
+
512
+ ws.send(
513
+ JSON.stringify({
514
+ type: "logContent",
515
+ filename,
516
+ content,
517
+ requestId,
518
+ }),
519
+ );
520
+ } catch (error) {
521
+ ws.send(
522
+ JSON.stringify({
523
+ type: "error",
524
+ error: `Failed to read log: ${error.message}`,
525
+ requestId,
526
+ }),
527
+ );
528
+ }
529
+ }
530
+
531
+ // Fetch published applications from Fenwave app-builder
532
+ async function fetchFenwaveApps(userEntityRef) {
533
+ const backendUrl = getBackendUrl();
534
+ if (!backendUrl) {
535
+ console.warn("Backend URL not configured, skipping Fenwave apps");
536
+ return [];
537
+ }
538
+
539
+ try {
540
+ // Load session token for authentication
541
+ const session = loadSession();
542
+ if (!session || !session.token) {
543
+ console.warn(
544
+ "No valid session found, skipping Fenwave apps. Please run: fenwave login",
545
+ );
546
+ return [];
547
+ }
548
+
549
+ const url = `${backendUrl}/api/app-builder/applications`;
550
+
551
+ // Fetch both published apps and user's drafts
552
+ const [publishedResponse, draftResponse] = await Promise.all([
553
+ // Fetch all published apps
554
+ axios
555
+ .get(url, {
556
+ params: { status: "published" },
557
+ headers: {
558
+ "Content-Type": "application/json",
559
+ Authorization: `Bearer ${session.token}`,
560
+ },
561
+ timeout: 10000,
562
+ })
563
+ .catch((err) => {
564
+ const isConnRefused =
565
+ (err.message && err.message.includes("ECONNREFUSED")) ||
566
+ err.code === "ECONNREFUSED";
567
+
568
+ if (isConnRefused) {
569
+ console.error(
570
+ chalk.red(
571
+ "❌ Failed to fetch published apps: Please ensure Fenwave is running.",
572
+ ),
573
+ );
574
+ } else {
575
+ console.error(
576
+ chalk.red("❌ Failed to fetch published apps:", err.message),
577
+ );
578
+ }
579
+
580
+ return { data: [] };
581
+ }),
582
+ // Fetch user's draft apps
583
+ axios
584
+ .get(url, {
585
+ params: {
586
+ status: "draft",
587
+ created_by: userEntityRef,
588
+ },
589
+ headers: {
590
+ "Content-Type": "application/json",
591
+ Authorization: `Bearer ${session.token}`,
592
+ },
593
+ timeout: 10000,
594
+ })
595
+ .catch((err) => {
596
+ const isConnRefused =
597
+ (err.message && err.message.includes("ECONNREFUSED")) ||
598
+ err.code === "ECONNREFUSED";
599
+
600
+ if (isConnRefused) {
601
+ console.error(
602
+ chalk.red(
603
+ "❌ Failed to fetch draft apps: Please ensure Fenwave is running.",
604
+ ),
605
+ );
606
+ } else {
607
+ console.error(
608
+ chalk.red("❌ Failed to fetch draft apps:", err.message),
609
+ );
610
+ }
611
+
612
+ return { data: [] };
613
+ }),
614
+ ]);
615
+
616
+ const publishedApps = publishedResponse.data || [];
617
+ const draftApps = draftResponse.data || [];
618
+ const allApps = [...publishedApps, ...draftApps];
619
+
620
+ // Transform Fenwave apps to local app format (now async to check container status)
621
+ return await Promise.all(allApps.map((app) => transformFenwaveApp(app)));
622
+ } catch (error) {
623
+ console.error("Failed to fetch Fenwave apps:", error.message);
624
+ return [];
625
+ }
626
+ }
627
+
628
+ // Transform Fenwave app to local app format
629
+ async function transformFenwaveApp(fenwaveApp) {
630
+ // Fetch all versions for this app
631
+ const session = loadSession();
632
+ let allVersions = [];
633
+
634
+ const backendUrl = getBackendUrl();
635
+ if (session && session.token && backendUrl) {
636
+ try {
637
+ const versionsResponse = await axios.get(
638
+ `${backendUrl}/api/app-builder/applications/${fenwaveApp.id}/versions`,
639
+ {
640
+ headers: {
641
+ "Content-Type": "application/json",
642
+ Authorization: `Bearer ${session.token}`,
643
+ },
644
+ timeout: 10000,
645
+ },
646
+ );
647
+ allVersions = versionsResponse.data || [];
648
+ } catch (err) {
649
+ console.error(
650
+ `Failed to fetch versions for app ${fenwaveApp.id}:`,
651
+ err.message,
652
+ );
653
+ }
654
+ }
655
+
656
+ // If no versions found, use activeVersion from app
657
+ if (allVersions.length === 0 && fenwaveApp.activeVersion) {
658
+ allVersions = [fenwaveApp.activeVersion];
659
+ }
660
+
661
+ // Check if there are running Docker containers for this app
662
+ const appName = fenwaveApp.name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
663
+ const fwId = fenwaveApp.id;
664
+ const isDraft = fenwaveApp.status === "draft";
665
+ const appVersion = fenwaveApp.activeVersion?.version || "1.0.0";
666
+
667
+ // Get container info to determine which version is running (or: has ran) locally
668
+ let appContainers = [];
669
+ let runningVersion = null; // Track which version is actually running
670
+
671
+ try {
672
+ const allContainers = await docker.listContainers({ all: true });
673
+
674
+ // For published apps, find containers for ANY version of this app (not just the latest)
675
+ if (isDraft) {
676
+ appContainers = filterContainersByApp(
677
+ allContainers,
678
+ fwId,
679
+ isDraft,
680
+ appVersion,
681
+ );
682
+ } else {
683
+ // For published apps, filter by fwId only, not by version (use shared helper)
684
+ appContainers = filterContainersAnyVersion(allContainers, fwId);
685
+ }
686
+
687
+ // Try to determine the running version from container labels (deterministically)
688
+ if (appContainers.length > 0) {
689
+ // Select the most relevant container (running > newest created)
690
+ const selectedContainer = selectMostRelevantContainer(appContainers);
691
+ if (selectedContainer) {
692
+ const versionLabel = selectedContainer.Labels["app.version"];
693
+ if (versionLabel) {
694
+ runningVersion = versionLabel;
695
+ }
696
+ }
697
+ }
698
+ } catch (error) {
699
+ console.error(
700
+ `Error checking containers for app ${appName}:`,
701
+ error.message,
702
+ );
703
+ }
704
+
705
+ const containerCount = appContainers.length;
706
+ const hasRunningContainer = appContainers.some((c) => c.State === "running");
707
+ const appStatus = hasRunningContainer
708
+ ? "running"
709
+ : containerCount > 0
710
+ ? "stopped"
711
+ : "stopped";
712
+
713
+ // Calculate firstRun and lastRun times for containers
714
+ let firstRunTime = "Never";
715
+ let lastRunTime = "Never";
716
+
717
+ if (appContainers.length > 0) {
718
+ const creationTimestamps = appContainers.map((c) =>
719
+ new Date(c.Created * 1000).getTime(),
720
+ );
721
+ const oldestCreationTimestamp = Math.min(...creationTimestamps);
722
+
723
+ const startTimestamps = [];
724
+ for (const container of appContainers) {
725
+ try {
726
+ const containerInfo = await docker.getContainer(container.Id).inspect();
727
+ if (containerInfo.State.StartedAt) {
728
+ startTimestamps.push(
729
+ new Date(containerInfo.State.StartedAt).getTime(),
730
+ );
731
+ }
732
+ } catch (err) {
733
+ startTimestamps.push(new Date(container.Created * 1000).getTime());
734
+ }
735
+ }
736
+
737
+ const mostRecentStartTimestamp = Math.max(...startTimestamps);
738
+ const now = Date.now();
739
+
740
+ // Calculate firstRun
741
+ const firstDiff = now - oldestCreationTimestamp;
742
+ const firstMinutes = Math.floor(firstDiff / (1000 * 60));
743
+ const firstHours = Math.floor(firstMinutes / 60);
744
+ const firstDays = Math.floor(firstHours / 24);
745
+
746
+ if (firstDays > 0)
747
+ firstRunTime = `${firstDays} day${firstDays > 1 ? "s" : ""} ago`;
748
+ else if (firstHours > 0)
749
+ firstRunTime = `${firstHours} hour${firstHours > 1 ? "s" : ""} ago`;
750
+ else if (firstMinutes > 0)
751
+ firstRunTime = `${firstMinutes} minute${firstMinutes > 1 ? "s" : ""} ago`;
752
+ else firstRunTime = "Just now";
753
+
754
+ // Calculate lastRun
755
+ const lastDiff = now - mostRecentStartTimestamp;
756
+ const lastMinutes = Math.floor(lastDiff / (1000 * 60));
757
+ const lastHours = Math.floor(lastMinutes / 60);
758
+ const lastDays = Math.floor(lastHours / 24);
759
+
760
+ if (lastDays > 0)
761
+ lastRunTime = `${lastDays} day${lastDays > 1 ? "s" : ""} ago`;
762
+ else if (lastHours > 0)
763
+ lastRunTime = `${lastHours} hour${lastHours > 1 ? "s" : ""} ago`;
764
+ else if (lastMinutes > 0)
765
+ lastRunTime = `${lastMinutes} minute${lastMinutes > 1 ? "s" : ""} ago`;
766
+ else lastRunTime = "Just now";
767
+ }
768
+
769
+ // Transform all versions to include local run status
770
+ const transformedVersions = allVersions.map((version) => {
771
+ const nodes = version.nodes || [];
772
+ const componentTypes = nodes.map((n) => n?.data?.type).filter(Boolean);
773
+ const language = componentTypes[0] || "Unknown";
774
+
775
+ // Check if THIS specific version is the one running (or: that has ran) locally
776
+ const isThisVersionRunning =
777
+ runningVersion && version.version === runningVersion;
778
+ const versionHasRun = isThisVersionRunning && containerCount > 0;
779
+
780
+ return {
781
+ id: version.id,
782
+ version: version.version || "1.0.0",
783
+ status: version.status, // draft, published, or archived
784
+ description:
785
+ version.description || fenwaveApp.description || "Fenwave application",
786
+ nodes: nodes,
787
+ edges: version.edges || [],
788
+ tags: version.tags || [],
789
+ language: language,
790
+ created_at: version.created_at,
791
+ created_by: version.created_by,
792
+ // Mark if THIS SPECIFIC version has ran locally
793
+ hasRanLocally: versionHasRun,
794
+ containers: versionHasRun ? containerCount : 0,
795
+ firstRun: versionHasRun ? firstRunTime : "Never",
796
+ lastRun: versionHasRun ? lastRunTime : "Never",
797
+ appStatus: versionHasRun ? appStatus : "stopped",
798
+ };
799
+ });
800
+
801
+ // Get the active published version
802
+ const activeVersion =
803
+ allVersions.find((v) => v.status === "published") || allVersions[0] || {};
804
+ const versionToUse = runningVersion || activeVersion.version || "1.0.0";
805
+
806
+ // Get nodes/edges from the version that's actually being used
807
+ const versionData = runningVersion
808
+ ? allVersions.find((v) => v.version === runningVersion) || activeVersion
809
+ : activeVersion;
810
+
811
+ const nodes = versionData.nodes || [];
812
+ const componentTypes = nodes.map((n) => n?.data?.type).filter(Boolean);
813
+ const language = componentTypes[0] || "Unknown";
814
+
815
+ // Extract localSetup from the version being used
816
+ const localSetup = versionData.deploymentConfig?.localSetup || null;
817
+
818
+ return {
819
+ id: `fw-${fenwaveApp.id}`,
820
+ name: fenwaveApp.name,
821
+ description:
822
+ fenwaveApp.description ||
823
+ versionData.description ||
824
+ "Fenwave application",
825
+ language: language,
826
+ status: appStatus,
827
+ containers: containerCount,
828
+ firstRun: firstRunTime,
829
+ lastRun: lastRunTime,
830
+ url: "N/A", // Will be set after running
831
+ repo: fenwaveApp.metadata?.repo || "N/A",
832
+ icon: fenwaveApp.name.substring(0, 1).toUpperCase(),
833
+ source: "fenwave",
834
+ fwId: fenwaveApp.id,
835
+ fwStatus: activeVersion.status || fenwaveApp.status,
836
+ version: versionToUse,
837
+ tags: versionData.tags || [],
838
+ nodes: nodes,
839
+ edges: versionData.edges || [],
840
+ createdBy: fenwaveApp.created_by,
841
+ publishedAt: fenwaveApp.published_at,
842
+ publishedBy: fenwaveApp.published_by,
843
+ versions: transformedVersions,
844
+ localSetup: localSetup,
845
+ };
846
+ }
847
+
848
+ // Helper function to find apps (Fenwave only)
849
+ async function findApps(userEntityRef) {
850
+ try {
851
+ // Only fetch Fenwave apps (no local Docker containers)
852
+ const fenwaveApps = await fetchFenwaveApps(userEntityRef);
853
+
854
+ return fenwaveApps;
855
+ } catch (error) {
856
+ console.error("Error finding apps:", error);
857
+ return [];
858
+ }
859
+ }
860
+
861
+ // Find Docker Compose apps
862
+ async function findComposeApps() {
863
+ try {
864
+ // This is a placeholder. In a real implementation, you would scan for
865
+ // docker-compose.yml files in the user's projects directory
866
+ return [];
867
+ } catch (error) {
868
+ console.error("Error finding compose apps:", error);
869
+ return [];
870
+ }
871
+ }
872
+
873
+ // Group containers into apps based on labels or networks
874
+ async function groupContainersIntoApps() {
875
+ try {
876
+ const containers = await docker.listContainers({ all: true });
877
+ const apps = {};
878
+
879
+ const detectLanguage = (image) => {
880
+ const imageLower = image.toLowerCase();
881
+
882
+ if (imageLower.includes("node") || imageLower.includes("javascript"))
883
+ return "JavaScript";
884
+ if (imageLower.includes("python")) return "Python";
885
+ if (imageLower.includes("ruby")) return "Ruby";
886
+ if (imageLower.includes("php")) return "PHP";
887
+ if (imageLower.includes("java")) return "Java";
888
+ if (imageLower.includes("go")) return "Go";
889
+ if (imageLower.includes("rust")) return "Rust";
890
+ if (imageLower.includes("dotnet") || imageLower.includes("csharp"))
891
+ return "C#";
892
+
893
+ return "Unknown";
894
+ };
895
+
896
+ const formatRelativeTime = (timestamp) => {
897
+ const now = Date.now();
898
+ const diff = now - timestamp;
899
+
900
+ const minutes = Math.floor(diff / (1000 * 60));
901
+ const hours = Math.floor(minutes / 60);
902
+ const days = Math.floor(hours / 24);
903
+ const weeks = Math.floor(days / 7);
904
+
905
+ if (weeks > 0) return `${weeks} week${weeks > 1 ? "s" : ""} ago`;
906
+ if (days > 0) return `${days} day${days > 1 ? "s" : ""} ago`;
907
+ if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""} ago`;
908
+ if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""} ago`;
909
+ return "Just now";
910
+ };
911
+
912
+ // Group by common labels or networks
913
+ for (const container of containers) {
914
+ const appName =
915
+ container.Labels["com.docker.compose.project"] ||
916
+ container.Labels["app"] ||
917
+ container.Names[0].replace(/^\//, "").split("_")[0];
918
+
919
+ if (!appName) continue;
920
+
921
+ // Create or update app
922
+ if (!apps[appName]) {
923
+ const containerInfo = await docker.getContainer(container.Id).inspect();
924
+ const image = containerInfo.Config.Image;
925
+ const createdTime = new Date(containerInfo.Created).getTime();
926
+
927
+ // Use StartedAt for lastRun if available, otherwise fall back to Created
928
+ const startedAt = containerInfo.State.StartedAt
929
+ ? new Date(containerInfo.State.StartedAt).getTime()
930
+ : createdTime;
931
+
932
+ apps[appName] = {
933
+ id: `app-${appName}`,
934
+ name: appName,
935
+ description: `Application using ${image}`,
936
+ language: detectLanguage(image),
937
+ status: containerInfo.State.Running ? "running" : "stopped",
938
+ containers: 1,
939
+ firstRun: formatRelativeTime(createdTime),
940
+ lastRun: formatRelativeTime(startedAt),
941
+ oldestCreationTime: createdTime, // Track oldest creation for firstRun
942
+ newestStartTime: startedAt, // Track most recent start for lastRun
943
+ url: `http://localhost:${
944
+ containerInfo.NetworkSettings.Ports
945
+ ? Object.keys(containerInfo.NetworkSettings.Ports)[0]?.split(
946
+ "/",
947
+ )[0]
948
+ : "8080"
949
+ }`,
950
+ repo: container.Labels["repo"] || "local",
951
+ icon: appName.substring(0, 1).toUpperCase(),
952
+ };
953
+ } else {
954
+ // Update app with additional container
955
+ apps[appName].containers += 1;
956
+
957
+ // Update status if any container is running
958
+ const containerInfo = await docker.getContainer(container.Id).inspect();
959
+ if (containerInfo.State.Running) {
960
+ apps[appName].status = "running";
961
+ }
962
+
963
+ // Track oldest creation time and newest start time
964
+ const createdTime = new Date(containerInfo.Created).getTime();
965
+ const startedAt = containerInfo.State.StartedAt
966
+ ? new Date(containerInfo.State.StartedAt).getTime()
967
+ : createdTime;
968
+
969
+ // Update firstRun if this container was created earlier
970
+ if (createdTime < apps[appName].oldestCreationTime) {
971
+ apps[appName].oldestCreationTime = createdTime;
972
+ apps[appName].firstRun = formatRelativeTime(createdTime);
973
+ }
974
+
975
+ // Update lastRun if this container was started more recently
976
+ if (startedAt > apps[appName].newestStartTime) {
977
+ apps[appName].newestStartTime = startedAt;
978
+ apps[appName].lastRun = formatRelativeTime(startedAt);
979
+ }
980
+ }
981
+ }
982
+
983
+ // Clean up temporary tracking fields before returning
984
+ const cleanedApps = Object.values(apps).map((app) => {
985
+ const { oldestCreationTime, newestStartTime, ...cleanApp } = app;
986
+ return cleanApp;
987
+ });
988
+
989
+ return cleanedApps;
990
+ } catch (error) {
991
+ console.error("Error grouping containers into apps:", error);
992
+ return [];
993
+ }
994
+ }
995
+
996
+ async function handleFetchApps(ws, payload = {}) {
997
+ try {
998
+ // Extract user context from payload (passed from frontend)
999
+ const userEntityRef = payload.userEntityRef || null;
1000
+
1001
+ // Fetch apps (local + Fenwave)
1002
+ const apps = await findApps(userEntityRef);
1003
+
1004
+ ws.send(
1005
+ JSON.stringify({
1006
+ type: "apps",
1007
+ apps,
1008
+ requestId: payload.requestId,
1009
+ }),
1010
+ );
1011
+ } catch (error) {
1012
+ console.error("Error fetching apps:", error);
1013
+ ws.send(
1014
+ JSON.stringify({
1015
+ type: "error",
1016
+ error: "Failed to fetch apps: " + error.message,
1017
+ requestId: payload.requestId,
1018
+ }),
1019
+ );
1020
+ }
1021
+ }
1022
+
1023
+ async function handleFetchAppVersions(ws, payload) {
1024
+ try {
1025
+ const { fwId, requestId } = payload;
1026
+
1027
+ if (!fwId) {
1028
+ throw new Error("fwId is required");
1029
+ }
1030
+
1031
+ const backendUrl = getBackendUrl();
1032
+ if (!backendUrl) {
1033
+ throw new Error("Backend URL not configured");
1034
+ }
1035
+
1036
+ // Load session token for authentication
1037
+ const session = loadSession();
1038
+ if (!session || !session.token) {
1039
+ throw new Error("No valid session found. Please run: fenwave login");
1040
+ }
1041
+
1042
+ // Fetch app info to get the name for container matching
1043
+ const appUrl = `${backendUrl}/api/app-builder/applications/${fwId}`;
1044
+ const appResponse = await axios.get(appUrl, {
1045
+ headers: {
1046
+ "Content-Type": "application/json",
1047
+ Authorization: `Bearer ${session.token}`,
1048
+ },
1049
+ timeout: 10000,
1050
+ });
1051
+ const appData = appResponse.data;
1052
+ const appName = appData.name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
1053
+
1054
+ // Check for existing containers to determine running version
1055
+ let runningVersion = null;
1056
+ let containers = [];
1057
+ try {
1058
+ containers = await docker.listContainers({ all: true });
1059
+ containers = containers.filter((container) => {
1060
+ const containerName = container.Names[0].replace(/^\//, "");
1061
+ return (
1062
+ containerName.startsWith(appName) ||
1063
+ container.Labels["com.docker.compose.project"] === appName ||
1064
+ container.Labels["app"] === appName
1065
+ );
1066
+ });
1067
+ if (containers.length > 0 && containers[0].Labels["app.version"]) {
1068
+ runningVersion = containers[0].Labels["app.version"];
1069
+ }
1070
+ } catch (err) {
1071
+ console.warn("Error checking containers:", err.message);
1072
+ }
1073
+
1074
+ const url = `${backendUrl}/api/app-builder/applications/${fwId}/versions`;
1075
+ const response = await axios.get(url, {
1076
+ headers: {
1077
+ "Content-Type": "application/json",
1078
+ Authorization: `Bearer ${session.token}`,
1079
+ },
1080
+ timeout: 10000,
1081
+ });
1082
+
1083
+ const rawVersions = response.data || [];
1084
+
1085
+ // Enrich versions with local run status
1086
+ const versions = rawVersions.map((version) => {
1087
+ const isThisVersionRunning =
1088
+ runningVersion && version.version === runningVersion;
1089
+ const versionHasRun = isThisVersionRunning && containers.length > 0;
1090
+
1091
+ return {
1092
+ ...version,
1093
+ hasRanLocally: versionHasRun,
1094
+ containers: versionHasRun ? containers.length : 0,
1095
+ firstRun: versionHasRun ? "Yes" : "Never",
1096
+ lastRun: versionHasRun ? "Yes" : "Never",
1097
+ appStatus: versionHasRun
1098
+ ? containers.some((c) => c.State === "running")
1099
+ ? "running"
1100
+ : "stopped"
1101
+ : "stopped",
1102
+ };
1103
+ });
1104
+
1105
+ ws.send(
1106
+ JSON.stringify({
1107
+ type: "appVersions",
1108
+ versions,
1109
+ appId: fwId,
1110
+ requestId,
1111
+ }),
1112
+ );
1113
+ } catch (error) {
1114
+ console.error("Error fetching app versions:", error);
1115
+ ws.send(
1116
+ JSON.stringify({
1117
+ type: "error",
1118
+ error: "Failed to fetch app versions: " + error.message,
1119
+ requestId: payload.requestId,
1120
+ }),
1121
+ );
1122
+ }
1123
+ }
1124
+
1125
+ async function handleStartApp(ws, payload) {
1126
+ try {
1127
+ const { id, yamlContent, requestId } = payload;
1128
+
1129
+ // Extract user context from payload
1130
+ const userEntityRef = payload.userEntityRef || null;
1131
+
1132
+ // Case 1: Called from App Builder with yamlContent
1133
+ if (yamlContent && !id) {
1134
+ // Parse YAML and extract application name safely
1135
+ const appDefinition = yaml.load(yamlContent);
1136
+ const appNameRaw =
1137
+ appDefinition?.application?.name ?? appDefinition?.name;
1138
+
1139
+ if (!appNameRaw || typeof appNameRaw !== "string") {
1140
+ throw new Error("Application name not found or invalid in YAML");
1141
+ }
1142
+
1143
+ const appName = appNameRaw.trim();
1144
+ const normalizedAppName = appName
1145
+ .replace(/[^a-z0-9-]/gi, "-")
1146
+ .toLowerCase();
1147
+
1148
+ // Find the app in Fenwave
1149
+ const apps = await findApps(userEntityRef);
1150
+ const app = apps.find(
1151
+ (a) =>
1152
+ a.name === appName ||
1153
+ a.name.replace(/[^a-z0-9-]/gi, "-").toLowerCase() ===
1154
+ normalizedAppName,
1155
+ );
1156
+
1157
+ if (app) {
1158
+ // Get app identity info for container matching
1159
+ const isDraft = app.fwStatus === "draft";
1160
+ const appVersion = app.version || "1.0.0";
1161
+ const fwId = app.fwId || app.id;
1162
+
1163
+ // Check if containers exist for this specific app using centralized filter
1164
+ const allContainers = await docker.listContainers({ all: true });
1165
+ const appContainers = filterContainersByApp(
1166
+ allContainers,
1167
+ fwId,
1168
+ isDraft,
1169
+ appVersion,
1170
+ );
1171
+
1172
+ // If containers exist, handle start or report already running
1173
+ if (appContainers.length > 0) {
1174
+ const hasRunningContainer = appContainers.some(
1175
+ (c) => c.State === "running",
1176
+ );
1177
+
1178
+ if (hasRunningContainer) {
1179
+ console.log(
1180
+ `⚠️ App "${appName}" is already running. Use the DevApp to manage it.`,
1181
+ );
1182
+
1183
+ ws.send(
1184
+ JSON.stringify({
1185
+ type: "error",
1186
+ error: "Application is already running",
1187
+ message: `App "${appName}" is already running. Use the DevApp to manage it.`,
1188
+ isWarning: true,
1189
+ requestId,
1190
+ }),
1191
+ );
1192
+ return;
1193
+ }
1194
+
1195
+ // Create a logger for this app run
1196
+ const logger = createAppLogger(app.name, appVersion);
1197
+
1198
+ if (isDraft) {
1199
+ logger.info(`Starting draft "${app.name}"...`);
1200
+ } else {
1201
+ logger.info(
1202
+ `Starting application "${app.name}" version ${appVersion}...`,
1203
+ );
1204
+ }
1205
+
1206
+ // Start any stopped containers
1207
+ for (const container of appContainers) {
1208
+ if (container.State !== "running") {
1209
+ logger.info(`Starting container ${container.Names[0]}...`);
1210
+ await docker.getContainer(container.Id).start();
1211
+ }
1212
+ }
1213
+
1214
+ if (isDraft) {
1215
+ logger.success(`Draft "${app.name}" started successfully!`);
1216
+ } else {
1217
+ logger.success(
1218
+ `Application "${app.name}" version ${appVersion} started successfully!`,
1219
+ );
1220
+ }
1221
+
1222
+ logger.complete(true, "Application started");
1223
+
1224
+ ws.send(
1225
+ JSON.stringify({
1226
+ type: "runCompleted",
1227
+ app: {
1228
+ ...app,
1229
+ status: "running",
1230
+ containers: appContainers.length,
1231
+ },
1232
+ requestId,
1233
+ logFile: logger.filename,
1234
+ }),
1235
+ );
1236
+ return;
1237
+ }
1238
+
1239
+ // No containers exist - run the app for the first time
1240
+ await runFenwaveApp(ws, app, yamlContent, requestId);
1241
+ return;
1242
+ }
1243
+
1244
+ // App not found in Fenwave - run as a new YAML-based app
1245
+ const newApp = {
1246
+ id: `app-${Date.now()}`,
1247
+ name: appName,
1248
+ fwId: null,
1249
+ source: "yaml",
1250
+ };
1251
+
1252
+ await runFenwaveApp(ws, newApp, yamlContent, requestId);
1253
+ return;
1254
+ }
1255
+
1256
+ // Case 2: Called from local app list with id
1257
+ if (!id) {
1258
+ throw new Error("Either id or yamlContent is required");
1259
+ }
1260
+
1261
+ // Find the app
1262
+ const apps = await findApps(userEntityRef);
1263
+ const app = apps.find((a) => a.id === id);
1264
+
1265
+ if (!app) {
1266
+ throw new Error("App not found");
1267
+ }
1268
+
1269
+ const isDraft = app.fwStatus === "draft";
1270
+ const fwId = app.fwId || app.id;
1271
+
1272
+ // First, find all containers for this app to determine the actual running version
1273
+ const allContainers = await docker.listContainers({ all: true });
1274
+ let actualVersion = app.version || "1.0.0";
1275
+
1276
+ if (!isDraft) {
1277
+ const anyAppContainers = filterContainersAnyVersion(allContainers, fwId);
1278
+
1279
+ // If containers exist, use the version from the most relevant container
1280
+ // (prioritizes running > newest created to avoid picking stale versions)
1281
+ if (anyAppContainers.length > 0) {
1282
+ const selectedContainer = selectMostRelevantContainer(anyAppContainers);
1283
+ if (selectedContainer) {
1284
+ const versionLabel = selectedContainer.Labels["app.version"];
1285
+ if (versionLabel) {
1286
+ actualVersion = versionLabel;
1287
+ }
1288
+ }
1289
+ }
1290
+ }
1291
+
1292
+ const appVersion = actualVersion;
1293
+
1294
+ // Find containers belonging to this specific app version using centralized filter
1295
+ const appContainers = filterContainersByApp(
1296
+ allContainers,
1297
+ fwId,
1298
+ isDraft,
1299
+ appVersion,
1300
+ );
1301
+
1302
+ // If containers exist, just start them (don't re-run)
1303
+ if (appContainers.length > 0) {
1304
+ // Get version from container labels (the actual running version)
1305
+ const runningVersion =
1306
+ appContainers[0].Labels["app.version"] || app.version || "1.0.0";
1307
+
1308
+ // Create a logger for this app run
1309
+ const logger = createAppLogger(app.name, runningVersion);
1310
+
1311
+ // Log with appropriate format for draft vs published
1312
+ if (isDraft) {
1313
+ logger.info(`Starting draft "${app.name}"...`);
1314
+ } else {
1315
+ logger.info(
1316
+ `Starting application "${app.name}" version ${runningVersion}...`,
1317
+ );
1318
+ }
1319
+
1320
+ for (const container of appContainers) {
1321
+ if (container.State !== "running") {
1322
+ logger.info(`Starting container ${container.Names[0]}...`);
1323
+ await docker.getContainer(container.Id).start();
1324
+ }
1325
+ }
1326
+
1327
+ if (isDraft) {
1328
+ logger.success(`Draft "${app.name}" started successfully!`);
1329
+ } else {
1330
+ logger.success(
1331
+ `Application "${app.name}" version ${runningVersion} started successfully!`,
1332
+ );
1333
+ }
1334
+
1335
+ logger.complete(true, "Application started");
1336
+
1337
+ ws.send(
1338
+ JSON.stringify({
1339
+ type: "appStarted",
1340
+ app: {
1341
+ ...app,
1342
+ status: "running",
1343
+ containers: appContainers.length,
1344
+ },
1345
+ requestId,
1346
+ logFile: logger.filename,
1347
+ }),
1348
+ );
1349
+ return;
1350
+ }
1351
+
1352
+ // No containers exist - run the app for the first time
1353
+ if (app.source === "fenwave" && app.nodes && app.nodes.length > 0) {
1354
+ // Convert Fenwave app to YAML and run
1355
+ const yamlContent = convertNodesToYAML(app);
1356
+ await runFenwaveApp(ws, app, yamlContent, requestId);
1357
+ return;
1358
+ }
1359
+
1360
+ // No containers and not a runnable app
1361
+ throw new Error("No containers found for this app. Unable to start.");
1362
+ } catch (error) {
1363
+ console.error("Error starting app:", error.message || error);
1364
+ ws.send(
1365
+ JSON.stringify({
1366
+ type: "error",
1367
+ error: "Failed to start app: " + (error.message || String(error)),
1368
+ requestId: payload.requestId,
1369
+ }),
1370
+ );
1371
+ }
1372
+ }
1373
+
1374
+ async function handleStopApp(ws, payload) {
1375
+ try {
1376
+ const { id, requestId } = payload;
1377
+
1378
+ // Extract user context from payload
1379
+ const userEntityRef = payload.userEntityRef || null;
1380
+
1381
+ // Find the app
1382
+ const apps = await findApps(userEntityRef);
1383
+ const app = apps.find((a) => a.id === id);
1384
+
1385
+ if (!app) {
1386
+ throw new Error("App not found");
1387
+ }
1388
+
1389
+ const isDraft = app.fwStatus === "draft";
1390
+ const appVersion = app.version || "1.0.0";
1391
+ const fwId = app.fwId || app.id;
1392
+
1393
+ // Find containers belonging to this specific app using centralized filter
1394
+ const allContainers = await docker.listContainers({ all: true });
1395
+ const appContainers = filterContainersByApp(
1396
+ allContainers,
1397
+ fwId,
1398
+ isDraft,
1399
+ appVersion,
1400
+ );
1401
+
1402
+ // Get version from container labels (the actual running version)
1403
+ const runningVersion =
1404
+ appContainers.length > 0 && appContainers[0].Labels["app.version"]
1405
+ ? appContainers[0].Labels["app.version"]
1406
+ : app.version || "1.0.0";
1407
+
1408
+ // Log with appropriate format for draft vs published
1409
+ if (isDraft) {
1410
+ console.log(`⏹️ Stopping draft "${app.name}"...`);
1411
+ } else {
1412
+ console.log(
1413
+ `⏹️ Stopping application "${app.name} (${runningVersion})"...`,
1414
+ );
1415
+ }
1416
+
1417
+ // Stop all containers
1418
+ for (const container of appContainers) {
1419
+ if (container.State === "running") {
1420
+ await docker.getContainer(container.Id).stop();
1421
+ }
1422
+ }
1423
+
1424
+ // Success log with appropriate format
1425
+ if (isDraft) {
1426
+ console.log(`✅ Draft "${app.name}" stopped successfully!`);
1427
+ } else {
1428
+ console.log(
1429
+ `✅ Application "${app.name} (${runningVersion})" stopped successfully!`,
1430
+ );
1431
+ }
1432
+
1433
+ ws.send(
1434
+ JSON.stringify({
1435
+ type: "appStopped",
1436
+ app: {
1437
+ ...app,
1438
+ status: "stopped",
1439
+ },
1440
+ requestId,
1441
+ }),
1442
+ );
1443
+ } catch (error) {
1444
+ console.error("Error stopping app:", error);
1445
+ ws.send(
1446
+ JSON.stringify({
1447
+ type: "error",
1448
+ error: "Failed to stop app: " + error.message,
1449
+ requestId: payload.requestId,
1450
+ }),
1451
+ );
1452
+ }
1453
+ }
1454
+
1455
+ async function handleRestartApp(ws, payload) {
1456
+ try {
1457
+ const { id, requestId } = payload;
1458
+
1459
+ // Extract user context from payload
1460
+ const userEntityRef = payload.userEntityRef || null;
1461
+
1462
+ // Find the app
1463
+ const apps = await findApps(userEntityRef);
1464
+ const app = apps.find((a) => a.id === id);
1465
+
1466
+ if (!app) {
1467
+ throw new Error("App not found");
1468
+ }
1469
+
1470
+ const isDraft = app.fwStatus === "draft";
1471
+ const appVersion = app.version || "1.0.0";
1472
+ const fwId = app.fwId || app.id;
1473
+
1474
+ // Find containers belonging to this specific app using centralized filter
1475
+ const allContainers = await docker.listContainers({ all: true });
1476
+ const appContainers = filterContainersByApp(
1477
+ allContainers,
1478
+ fwId,
1479
+ isDraft,
1480
+ appVersion,
1481
+ );
1482
+
1483
+ // Get version from container labels (the actual running version)
1484
+ const runningVersion =
1485
+ appContainers.length > 0 && appContainers[0].Labels["app.version"]
1486
+ ? appContainers[0].Labels["app.version"]
1487
+ : app.version || "1.0.0";
1488
+
1489
+ // Log with appropriate format for draft vs published
1490
+ if (isDraft) {
1491
+ console.log(`🔄 Restarting draft "${app.name}"...`);
1492
+ } else {
1493
+ console.log(
1494
+ `🔄 Restarting application "${app.name} (${runningVersion})"...`,
1495
+ );
1496
+ }
1497
+
1498
+ // Restart all containers
1499
+ for (const container of appContainers) {
1500
+ await docker.getContainer(container.Id).restart();
1501
+ }
1502
+
1503
+ // Success log with appropriate format
1504
+ if (isDraft) {
1505
+ console.log(`✅ Draft "${app.name}" restarted successfully!`);
1506
+ } else {
1507
+ console.log(
1508
+ `✅ Application "${app.name} (${runningVersion})" restarted successfully!`,
1509
+ );
1510
+ }
1511
+
1512
+ ws.send(
1513
+ JSON.stringify({
1514
+ type: "appRestarted",
1515
+ app: {
1516
+ ...app,
1517
+ status: "running",
1518
+ containers: appContainers.length,
1519
+ },
1520
+ requestId,
1521
+ }),
1522
+ );
1523
+ } catch (error) {
1524
+ console.error("Error restarting app:", error);
1525
+ ws.send(
1526
+ JSON.stringify({
1527
+ type: "error",
1528
+ error: "Failed to restart app: " + error.message,
1529
+ requestId: payload.requestId,
1530
+ }),
1531
+ );
1532
+ }
1533
+ }
1534
+
1535
+ async function handleDeleteApp(ws, payload) {
1536
+ try {
1537
+ const { id, requestId } = payload;
1538
+
1539
+ // Extract user context from payload
1540
+ const userEntityRef = payload.userEntityRef || null;
1541
+
1542
+ // Find the app
1543
+ const apps = await findApps(userEntityRef);
1544
+ const app = apps.find((a) => a.id === id);
1545
+
1546
+ if (!app) {
1547
+ throw new Error("App not found");
1548
+ }
1549
+
1550
+ const appVersion = app.version || "1.0.0";
1551
+ const appId = app.fwId || app.id;
1552
+ const isDraft = app.fwStatus === "draft";
1553
+
1554
+ // Normalize app name for container matching
1555
+ const appName = app.name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
1556
+
1557
+ // Find containers belonging to this app
1558
+ const containers = await docker.listContainers({ all: true });
1559
+ const appContainers = containers.filter((container) => {
1560
+ const containerName = container.Names[0].replace(/^\//, "");
1561
+ const projectLabel = container.Labels["com.docker.compose.project"];
1562
+
1563
+ return (
1564
+ containerName.startsWith(appName) ||
1565
+ projectLabel === appName ||
1566
+ container.Labels["app"] === appName
1567
+ );
1568
+ });
1569
+
1570
+ // Remove all containers
1571
+ for (const container of appContainers) {
1572
+ await docker.getContainer(container.Id).remove({ force: true });
1573
+ }
1574
+
1575
+ // Delete workspace folder - use "draft" for drafts, version for published
1576
+ const workspacePath = isDraft
1577
+ ? resolveWorkspacePathForDraft(appId, appName, "delete")
1578
+ : resolveWorkspacePath(appId, appName, appVersion, "delete");
1579
+ const registryVersion = isDraft ? "draft" : appVersion;
1580
+
1581
+ if (fs.existsSync(workspacePath)) {
1582
+ deleteWorkspaceFolder(workspacePath);
1583
+ unregisterWorkspace(appId, registryVersion);
1584
+ }
1585
+
1586
+ // If app is from fenwave, delete from backend database
1587
+ if (app.source === "fenwave" && app.fwId) {
1588
+ // Log with appropriate format for draft vs published
1589
+ if (isDraft) {
1590
+ console.log(`🗑️ Deleting draft "${app.name}"...`);
1591
+ } else {
1592
+ console.log(
1593
+ `🗑️ Deleting application "${app.name} (${appVersion})"...`,
1594
+ );
1595
+ }
1596
+
1597
+ const session = loadSession();
1598
+ if (!session || !session.token) {
1599
+ console.error(
1600
+ chalk.red("❌ No active session found. Cannot delete from database."),
1601
+ );
1602
+ throw new Error("No active session. Please login first.");
1603
+ }
1604
+ const backendUrl = getBackendUrl();
1605
+ const deleteUrl = `${backendUrl}/api/app-builder/applications/${app.fwId}`;
1606
+
1607
+ try {
1608
+ await axios.delete(deleteUrl, {
1609
+ headers: {
1610
+ Authorization: `Bearer ${session.token}`,
1611
+ "Content-Type": "application/json",
1612
+ },
1613
+ timeout: 10000,
1614
+ });
1615
+ // Success log with appropriate format
1616
+ if (isDraft) {
1617
+ console.log(`✅ Draft "${app.name}" deleted successfully!`);
1618
+ } else {
1619
+ console.log(
1620
+ `✅ Application "${app.name} (${appVersion})" deleted successfully!`,
1621
+ );
1622
+ }
1623
+ } catch (dbError) {
1624
+ console.error(
1625
+ chalk.red("❌ Error deleting from database: ", dbError.message),
1626
+ );
1627
+
1628
+ // If it's an authentication error, throw it so the user knows
1629
+ if (dbError.response?.status === 401) {
1630
+ throw new Error(
1631
+ "Authentication failed. Please login again with: fenwave login",
1632
+ );
1633
+ }
1634
+ }
1635
+ }
1636
+
1637
+ ws.send(
1638
+ JSON.stringify({
1639
+ type: "appDeleted",
1640
+ id,
1641
+ success: true,
1642
+ requestId,
1643
+ }),
1644
+ );
1645
+ } catch (error) {
1646
+ console.error("Error deleting app:", error);
1647
+ ws.send(
1648
+ JSON.stringify({
1649
+ type: "error",
1650
+ error: "Failed to delete app: " + error.message,
1651
+ requestId: payload.requestId,
1652
+ }),
1653
+ );
1654
+ }
1655
+ }
1656
+
1657
+ /**
1658
+ * Handle cleaning an app - removes containers, volumes, networks, and workspace
1659
+ * Unlike deleteApp, this does NOT delete from Fenwave backend
1660
+ */
1661
+ async function handleCleanApp(ws, payload) {
1662
+ try {
1663
+ const { id, requestId } = payload;
1664
+
1665
+ // Extract user context from payload
1666
+ const userEntityRef = payload.userEntityRef || null;
1667
+
1668
+ // Find the app
1669
+ const apps = await findApps(userEntityRef);
1670
+ const app = apps.find((a) => a.id === id);
1671
+
1672
+ if (!app) {
1673
+ throw new Error("App not found");
1674
+ }
1675
+
1676
+ const appVersion = app.version || "1.0.0";
1677
+ const isDraft = app.fwStatus === "draft";
1678
+ const appName = app.name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
1679
+ const fwId = app.fwId || app.id;
1680
+ const appId = fwId;
1681
+
1682
+ // Log start of cleanup
1683
+ if (isDraft) {
1684
+ console.log(`🧹 Cleaning draft "${app.name}"...`);
1685
+ } else {
1686
+ console.log(`🧹 Cleaning application "${app.name}" (${appVersion})...`);
1687
+ }
1688
+
1689
+ // Find containers belonging to this app
1690
+ const allContainers = await docker.listContainers({ all: true });
1691
+
1692
+ // Use centralized filter for labeled containers
1693
+ let appContainers = filterContainersByApp(
1694
+ allContainers,
1695
+ fwId,
1696
+ isDraft,
1697
+ appVersion,
1698
+ );
1699
+
1700
+ // Also include fallback for older containers without fwId label (for cleanup only)
1701
+ if (appContainers.length === 0) {
1702
+ appContainers = allContainers.filter((container) => {
1703
+ const projectLabel = container.Labels["com.docker.compose.project"];
1704
+ return (
1705
+ projectLabel === appName ||
1706
+ projectLabel === `${appName}-${appVersion}`
1707
+ );
1708
+ });
1709
+ }
1710
+
1711
+ // Collect networks and volumes before removing containers
1712
+ const networksToRemove = new Set();
1713
+ const volumesToRemove = new Set();
1714
+
1715
+ for (const container of appContainers) {
1716
+ // Get container details to find networks and volumes
1717
+ const containerInfo = await docker.getContainer(container.Id).inspect();
1718
+
1719
+ // Collect networks
1720
+ if (containerInfo.NetworkSettings?.Networks) {
1721
+ Object.keys(containerInfo.NetworkSettings.Networks).forEach(
1722
+ (network) => {
1723
+ // Don't remove default networks
1724
+ if (!["bridge", "host", "none"].includes(network)) {
1725
+ networksToRemove.add(network);
1726
+ }
1727
+ },
1728
+ );
1729
+ }
1730
+
1731
+ // Collect volumes
1732
+ if (containerInfo.Mounts) {
1733
+ containerInfo.Mounts.forEach((mount) => {
1734
+ if (mount.Type === "volume" && mount.Name) {
1735
+ volumesToRemove.add(mount.Name);
1736
+ }
1737
+ });
1738
+ }
1739
+ }
1740
+
1741
+ // Stop and remove all containers
1742
+ let containersRemoved = 0;
1743
+ for (const container of appContainers) {
1744
+ try {
1745
+ const containerObj = docker.getContainer(container.Id);
1746
+ if (container.State === "running") {
1747
+ await containerObj.stop();
1748
+ }
1749
+ await containerObj.remove({ force: true });
1750
+ containersRemoved++;
1751
+ } catch (err) {
1752
+ console.warn(
1753
+ `⚠️ Failed to remove container ${container.Names[0]}: ${err.message}`,
1754
+ );
1755
+ }
1756
+ }
1757
+ console.log(` Removed ${containersRemoved} container(s)`);
1758
+
1759
+ // Remove volumes
1760
+ let volumesRemoved = 0;
1761
+ for (const volumeName of volumesToRemove) {
1762
+ try {
1763
+ await docker.getVolume(volumeName).remove();
1764
+ volumesRemoved++;
1765
+ } catch (err) {
1766
+ // Volume might be in use by other containers or already removed
1767
+ if (
1768
+ !err.message.includes("in use") &&
1769
+ !err.message.includes("No such volume")
1770
+ ) {
1771
+ console.warn(
1772
+ `⚠️ Failed to remove volume ${volumeName}: ${err.message}`,
1773
+ );
1774
+ }
1775
+ }
1776
+ }
1777
+ if (volumesRemoved > 0) {
1778
+ console.log(` Removed ${volumesRemoved} volume(s)`);
1779
+ }
1780
+
1781
+ // Remove networks
1782
+ let networksRemoved = 0;
1783
+ for (const networkName of networksToRemove) {
1784
+ try {
1785
+ await docker.getNetwork(networkName).remove();
1786
+ networksRemoved++;
1787
+ } catch (err) {
1788
+ // Network might be in use or already removed
1789
+ if (
1790
+ !err.message.includes("has active endpoints") &&
1791
+ !err.message.includes("No such network")
1792
+ ) {
1793
+ console.warn(
1794
+ `⚠️ Failed to remove network ${networkName}: ${err.message}`,
1795
+ );
1796
+ }
1797
+ }
1798
+ }
1799
+ if (networksRemoved > 0) {
1800
+ console.log(` Removed ${networksRemoved} network(s)`);
1801
+ }
1802
+
1803
+ // Delete workspace folder
1804
+ const workspacePath = isDraft
1805
+ ? resolveWorkspacePathForDraft(appId, appName, "delete")
1806
+ : resolveWorkspacePath(appId, appName, appVersion, "delete");
1807
+ const registryVersion = isDraft ? "draft" : appVersion;
1808
+
1809
+ if (fs.existsSync(workspacePath)) {
1810
+ deleteWorkspaceFolder(workspacePath);
1811
+ unregisterWorkspace(appId, registryVersion);
1812
+ console.log(` Removed workspace folder`);
1813
+ }
1814
+
1815
+ // Success log
1816
+ if (isDraft) {
1817
+ console.log(`✅ Draft "${app.name}" cleaned successfully!`);
1818
+ } else {
1819
+ console.log(
1820
+ `✅ Application "${app.name}" (${appVersion}) cleaned successfully!`,
1821
+ );
1822
+ }
1823
+
1824
+ ws.send(
1825
+ JSON.stringify({
1826
+ type: "appCleaned",
1827
+ id,
1828
+ success: true,
1829
+ containersRemoved,
1830
+ volumesRemoved,
1831
+ networksRemoved,
1832
+ requestId,
1833
+ }),
1834
+ );
1835
+ } catch (error) {
1836
+ console.error("Error cleaning app:", error);
1837
+ ws.send(
1838
+ JSON.stringify({
1839
+ type: "error",
1840
+ error: "Failed to clean app: " + error.message,
1841
+ requestId: payload.requestId,
1842
+ }),
1843
+ );
1844
+ }
1845
+ }
1846
+
1847
+ /**
1848
+ * Handle deletion of specific app versions
1849
+ */
1850
+ async function handleDeleteAppVersions(ws, payload) {
1851
+ try {
1852
+ const { appName, fwId, versions, requestId } = payload;
1853
+
1854
+ if (!appName || !versions || versions.length === 0) {
1855
+ throw new Error("appName and versions are required");
1856
+ }
1857
+
1858
+ const normalizedAppName = appName
1859
+ .replace(/[^a-z0-9-]/gi, "-")
1860
+ .toLowerCase();
1861
+ const appId = fwId || appName;
1862
+
1863
+ const versionWord = versions.length === 1 ? "version" : "versions";
1864
+
1865
+ const deletedVersions = [];
1866
+ const failedVersions = [];
1867
+
1868
+ for (const version of versions) {
1869
+ const isDraft = version === "draft";
1870
+
1871
+ try {
1872
+ if (isDraft) {
1873
+ console.log(`🗑️ Deleting draft "${appName}"...`);
1874
+ } else {
1875
+ console.log(`🗑️ Deleting version ${version} of "${appName}"...`);
1876
+ }
1877
+
1878
+ // Find containers for this specific version
1879
+ const containers = await docker.listContainers({ all: true });
1880
+ const versionContainers = containers.filter((container) => {
1881
+ const containerName = container.Names[0].replace(/^\//, "");
1882
+ const projectLabel = container.Labels["com.docker.compose.project"];
1883
+ const containerVersion = container.Labels["app.version"];
1884
+
1885
+ const matchesApp =
1886
+ containerName.startsWith(normalizedAppName) ||
1887
+ projectLabel === normalizedAppName ||
1888
+ container.Labels["app"] === normalizedAppName;
1889
+
1890
+ if (!matchesApp) return false;
1891
+
1892
+ // For drafts, match containers without version label or with draft project name
1893
+ if (isDraft) {
1894
+ return !containerVersion || projectLabel === normalizedAppName;
1895
+ }
1896
+
1897
+ // If container has version label, match by version
1898
+ if (containerVersion) {
1899
+ const matchesVersion = containerVersion === version;
1900
+ return matchesVersion;
1901
+ }
1902
+
1903
+ // If no version label, also match by project name suffix (e.g., app-name-v1.0.0)
1904
+ // This handles containers created before version labels were added
1905
+ const matchesByName =
1906
+ projectLabel === `${normalizedAppName}-${version}` ||
1907
+ containerName.includes(`-${version}`);
1908
+ return matchesByName;
1909
+ });
1910
+
1911
+ // Remove containers for this version (force remove even if running)
1912
+ for (const container of versionContainers) {
1913
+ try {
1914
+ await docker.getContainer(container.Id).remove({ force: true });
1915
+ } catch (removeErr) {
1916
+ console.warn(
1917
+ ` ⚠️ Failed to remove container ${container.Names[0]}: ${removeErr.message}`,
1918
+ );
1919
+ }
1920
+ }
1921
+
1922
+ // Delete workspace folder for this version
1923
+ // For drafts, use the draft workspace path
1924
+ const workspacePath = isDraft
1925
+ ? resolveWorkspacePathForDraft(appId, normalizedAppName, "delete")
1926
+ : resolveWorkspacePath(appId, normalizedAppName, version, "delete");
1927
+ const registryKey = isDraft ? "draft" : version;
1928
+
1929
+ if (fs.existsSync(workspacePath)) {
1930
+ deleteWorkspaceFolder(workspacePath);
1931
+ unregisterWorkspace(appId, registryKey);
1932
+ }
1933
+
1934
+ if (isDraft) {
1935
+ console.log(`✅ Draft deleted successfully`);
1936
+ } else {
1937
+ console.log(`✅ Version ${version} deleted successfully`);
1938
+ }
1939
+ deletedVersions.push(version);
1940
+ } catch (versionError) {
1941
+ console.error(
1942
+ chalk.red(
1943
+ ` ❌ Failed to delete version ${version}:`,
1944
+ versionError.message,
1945
+ ),
1946
+ );
1947
+ failedVersions.push({ version, error: versionError.message });
1948
+ }
1949
+ }
1950
+
1951
+ ws.send(
1952
+ JSON.stringify({
1953
+ type: "appVersionsDeleted",
1954
+ appName,
1955
+ fwId,
1956
+ deletedVersions,
1957
+ failedVersions,
1958
+ success: failedVersions.length === 0,
1959
+ requestId,
1960
+ }),
1961
+ );
1962
+ } catch (error) {
1963
+ console.error("Error deleting app versions:", error);
1964
+ ws.send(
1965
+ JSON.stringify({
1966
+ type: "error",
1967
+ error: "Failed to delete app versions: " + error.message,
1968
+ requestId: payload.requestId,
1969
+ }),
1970
+ );
1971
+ }
1972
+ }
1973
+
1974
+ async function handleCreateApp(ws, payload) {
1975
+ try {
1976
+ const { templateName, yamlContent, requestId } = payload;
1977
+ const backendUrl = getBackendUrl();
1978
+ const url = `${backendUrl}/api/app-builder/import-template`;
1979
+ try {
1980
+ await axios.post(
1981
+ url,
1982
+ {
1983
+ templateName,
1984
+ yamlContent,
1985
+ },
1986
+ {
1987
+ headers: {
1988
+ "Content-Type": "application/json",
1989
+ },
1990
+ timeout: 10000,
1991
+ },
1992
+ );
1993
+
1994
+ ws.send(
1995
+ JSON.stringify({
1996
+ type: "appCreated",
1997
+ templateName,
1998
+ success: true,
1999
+ forwardedToComposer: true,
2000
+ requestId,
2001
+ }),
2002
+ );
2003
+ } catch (forwardError) {
2004
+ console.error(
2005
+ chalk.red(
2006
+ "❌ Failed to forward template to app-builder:",
2007
+ forwardError.message,
2008
+ ),
2009
+ );
2010
+
2011
+ // Still log the template locally even if forwarding fails
2012
+ ws.send(
2013
+ JSON.stringify({
2014
+ type: "appCreated",
2015
+ templateName,
2016
+ success: true,
2017
+ forwardedToComposer: false,
2018
+ forwardError: forwardError.message,
2019
+ requestId,
2020
+ }),
2021
+ );
2022
+ }
2023
+ } catch (error) {
2024
+ console.error("Error logging template:", error);
2025
+ ws.send(
2026
+ JSON.stringify({
2027
+ type: "error",
2028
+ error: "Failed to log template: " + error.message,
2029
+ requestId: payload.requestId,
2030
+ }),
2031
+ );
2032
+ }
2033
+ }
2034
+
2035
+ /**
2036
+ * Helper function to create a new bind mount (file or directory)
2037
+ * @param {string} appDownloadDir - The app workspace directory
2038
+ * @param {object} bindMount - The bind mount configuration
2039
+ * @param {string} baseName - The base name extracted from container path
2040
+ * @param {boolean} isDirectory - Whether this is a directory mount
2041
+ * @param {string} randomSuffix - Random suffix for the file/directory name
2042
+ * @returns {{hostPath: string, log: string}} The relative host path for docker-compose and log message
2043
+ */
2044
+ function createNewBindMount(
2045
+ appDownloadDir,
2046
+ bindMount,
2047
+ baseName,
2048
+ isDirectory,
2049
+ randomSuffix,
2050
+ ) {
2051
+ if (isDirectory) {
2052
+ // For directory mounts, create a directory and a file inside it
2053
+ const hostDirName = `${baseName}.${randomSuffix}`;
2054
+ const hostDirPath = path.join(appDownloadDir, hostDirName);
2055
+
2056
+ // Create the directory
2057
+ if (!fs.existsSync(hostDirPath)) {
2058
+ fs.mkdirSync(hostDirPath, { recursive: true });
2059
+ }
2060
+
2061
+ // Create a default file inside the directory
2062
+ const fileName = `${baseName}.toml`;
2063
+ const hostFilePath = path.join(hostDirPath, fileName);
2064
+ fs.writeFileSync(hostFilePath, bindMount.content, "utf8");
2065
+ const log = `📁 Created bind mount directory: ${hostDirName}/ with file: ${fileName}`;
2066
+
2067
+ return { hostPath: `./${hostDirName}`, log };
2068
+ } else {
2069
+ // For file mounts, create the file directly
2070
+ const hostFileName = `${baseName}.${randomSuffix}`;
2071
+ const hostFilePath = path.join(appDownloadDir, hostFileName);
2072
+
2073
+ // Write the content to the file
2074
+ fs.writeFileSync(hostFilePath, bindMount.content, "utf8");
2075
+ const log = `📝 Created bind mount file: ${hostFileName}`;
2076
+
2077
+ return { hostPath: `./${hostFileName}`, log };
2078
+ }
2079
+ }
2080
+
2081
+ function generateDockerCompose(
2082
+ appDefinition,
2083
+ outputPath,
2084
+ dockerComposeConfig = null,
2085
+ ) {
2086
+ if (!appDefinition.application) {
2087
+ throw new Error('Invalid YAML: missing "application" key');
2088
+ }
2089
+
2090
+ const name = appDefinition.application.name;
2091
+ const version = appDefinition.application.version || "1.0.0";
2092
+
2093
+ if (!name || typeof name !== "string") {
2094
+ console.error(chalk.red("❌ Invalid application name:", name));
2095
+ throw new Error(
2096
+ `Invalid application name: expected string, got ${typeof name}`,
2097
+ );
2098
+ }
2099
+
2100
+ const services = {};
2101
+ const volumes = {};
2102
+ const networks = {};
2103
+
2104
+ for (const component of appDefinition.application.components) {
2105
+ const serviceName = component.name.toLowerCase();
2106
+ const service = {};
2107
+
2108
+ // Handle image or build context
2109
+ if (component.docker?.build && component.docker?.dockerfile) {
2110
+ service.build = {
2111
+ context: ".",
2112
+ dockerfile_inline: component.docker.dockerfile
2113
+ .trim()
2114
+ .replace(/\\n/g, "\n"),
2115
+ };
2116
+ } else if (component.docker?.image) {
2117
+ service.image = component.docker.image;
2118
+ }
2119
+
2120
+ // container_name
2121
+ service.container_name = serviceName;
2122
+
2123
+ // Hostname
2124
+ if (component.hostname) {
2125
+ service.hostname = component.hostname;
2126
+ }
2127
+
2128
+ // Add labels to track the app identity
2129
+ service.labels = {
2130
+ "app.version": version,
2131
+ "app.name": name,
2132
+ "app.fwId": appDefinition.application.fwId || "",
2133
+ "app.isDraft": appDefinition.application.isDraft ? "true" : "false",
2134
+ };
2135
+
2136
+ // Add custom labels if present
2137
+ if (component.labels && component.labels.length > 0) {
2138
+ component.labels.forEach((labelStr) => {
2139
+ if (typeof labelStr === "string" && labelStr.includes("=")) {
2140
+ const [key, ...valueParts] = labelStr.split("=");
2141
+ service.labels[key] = valueParts.join("=");
2142
+ }
2143
+ });
2144
+ }
2145
+
2146
+ // Ports - support multiple ports
2147
+ if (component.ports && Array.isArray(component.ports)) {
2148
+ // Multiple ports format - normalize single ports to host:container format
2149
+ service.ports = component.ports.map((p) => {
2150
+ const portStr = String(p);
2151
+ // If already in host:container format, use as-is; otherwise, map same port
2152
+ return portStr.includes(":") ? portStr : `${portStr}:${portStr}`;
2153
+ });
2154
+ } else if (component.port) {
2155
+ // Legacy single port support
2156
+ // For databases, map to their standard internal ports
2157
+ const standardPorts = {
2158
+ mysql: "3306",
2159
+ postgresql: "5432",
2160
+ mongo: "27017",
2161
+ redis: "6379",
2162
+ rabbitmq: "5672",
2163
+ elasticsearch: "9200",
2164
+ };
2165
+
2166
+ const internalPort = standardPorts[component.type] || component.port;
2167
+ service.ports = [`${component.port}:${internalPort}`];
2168
+ }
2169
+
2170
+ // Command - preserve user's format
2171
+ if (component.command && component.command.length > 0) {
2172
+ service.command = component.command;
2173
+ }
2174
+
2175
+ // Environment variables
2176
+ if (component.env) {
2177
+ service.environment = component.env;
2178
+ }
2179
+
2180
+ // env_file - extract file names only, content will be written separately
2181
+ if (component.env_file && component.env_file.length > 0) {
2182
+ service.env_file = component.env_file.map((ef) =>
2183
+ typeof ef === "string" ? ef : `./${ef.name || ".env"}`,
2184
+ );
2185
+ }
2186
+
2187
+ // Memory options
2188
+ if (component.mem_reservation) {
2189
+ service.mem_reservation = component.mem_reservation;
2190
+ }
2191
+ if (component.mem_limit) {
2192
+ service.mem_limit = component.mem_limit;
2193
+ }
2194
+
2195
+ // Apply global run config environment variables
2196
+ if (dockerComposeConfig && dockerComposeConfig.containerEnv) {
2197
+ service.environment = {
2198
+ ...dockerComposeConfig.containerEnv,
2199
+ ...(service.environment || {}),
2200
+ };
2201
+ }
2202
+
2203
+ // depends_on
2204
+ if (
2205
+ component.depends_on &&
2206
+ Array.isArray(component.depends_on) &&
2207
+ component.depends_on.length > 0
2208
+ ) {
2209
+ service.depends_on = component.depends_on.map((dep) => dep.toLowerCase());
2210
+ }
2211
+
2212
+ // Restart policy
2213
+ if (component.restartPolicy) {
2214
+ service.restart = component.restartPolicy;
2215
+ }
2216
+
2217
+ // User (run container as specific user)
2218
+ if (component.user) {
2219
+ service.user = component.user;
2220
+ }
2221
+
2222
+ // Volumes
2223
+ if (component.volumes?.length) {
2224
+ service.volumes = component.volumes
2225
+ .filter((vol) => vol.name && (vol.mountPath || vol.mount_path))
2226
+ .map((vol) => {
2227
+ const mountPath = vol.mountPath || vol.mount_path;
2228
+ return `${vol.name}:${mountPath}`;
2229
+ });
2230
+
2231
+ // Only add named volumes to top-level volumes (not bind mounts/host paths)
2232
+ component.volumes.forEach((vol) => {
2233
+ if (vol.name && !volumes[vol.name]) {
2234
+ // Skip bind mounts (paths starting with ./ or /)
2235
+ if (!vol.name.startsWith("./") && !vol.name.startsWith("/")) {
2236
+ volumes[vol.name] = { driver: "local" };
2237
+ }
2238
+ }
2239
+ });
2240
+ }
2241
+
2242
+ // Bind mounts with content - these will be handled separately
2243
+ // Store bind mounts for later file creation
2244
+ if (component.bindMounts?.length) {
2245
+ // Bind mounts will be processed after files are created
2246
+ // The _hostPath will be added to each bindMount during file creation
2247
+ }
2248
+
2249
+ // Networks
2250
+ const builtinNetworks = ["bridge", "host", "none"];
2251
+ const componentNetworks = component.networks || [];
2252
+
2253
+ const builtinSelected = componentNetworks.filter((n) =>
2254
+ builtinNetworks.includes(n.name || n),
2255
+ );
2256
+ const customSelected = componentNetworks.filter(
2257
+ (n) => !builtinNetworks.includes(n.name || n),
2258
+ );
2259
+
2260
+ if (builtinSelected.length === 1 && componentNetworks.length === 1) {
2261
+ service.network_mode = builtinSelected[0].name || builtinSelected[0];
2262
+ } else if (componentNetworks.length === 0) {
2263
+ // default to bridge
2264
+ } else {
2265
+ // Handle custom networks with aliases support
2266
+ const serviceNetworks = {};
2267
+ customSelected.forEach((n) => {
2268
+ const name = typeof n === "string" ? n : n.name;
2269
+ const aliases =
2270
+ typeof n === "object" && n.aliases && n.aliases.length > 0
2271
+ ? n.aliases
2272
+ : null;
2273
+
2274
+ if (aliases) {
2275
+ serviceNetworks[name] = { aliases };
2276
+ } else {
2277
+ serviceNetworks[name] = null;
2278
+ }
2279
+ });
2280
+
2281
+ // If any network has aliases, use object format; otherwise use array
2282
+ const hasAliases = Object.values(serviceNetworks).some((v) => v !== null);
2283
+ if (hasAliases) {
2284
+ service.networks = serviceNetworks;
2285
+ } else {
2286
+ service.networks = customSelected.map((n) =>
2287
+ typeof n === "string" ? n : n.name,
2288
+ );
2289
+ }
2290
+
2291
+ // Define networks globally
2292
+ customSelected.forEach((n) => {
2293
+ const name = typeof n === "string" ? n : n.name;
2294
+ if (!networks[name]) {
2295
+ networks[name] = {
2296
+ driver: typeof n === "object" && n.driver ? n.driver : "bridge",
2297
+ };
2298
+ }
2299
+ });
2300
+ }
2301
+
2302
+ // Apply global run config resources and scaling
2303
+ if (dockerComposeConfig) {
2304
+ const deploy = generateComposeDeploy(
2305
+ dockerComposeConfig.resources,
2306
+ dockerComposeConfig.replicas,
2307
+ );
2308
+ if (deploy) {
2309
+ service.deploy = deploy;
2310
+ }
2311
+ }
2312
+
2313
+ // Process bind mounts - add to volumes array
2314
+ if (component.bindMounts?.length) {
2315
+ const bindMountVolumes = component.bindMounts
2316
+ .filter((bm) => {
2317
+ // For 'generated' mode, require _hostPath
2318
+ if (bm.mode === "generated" || !bm.mode) {
2319
+ return bm.containerPath && bm._hostPath;
2320
+ }
2321
+ // For 'existing' mode, require hostPath
2322
+ if (bm.mode === "existing") {
2323
+ return bm.containerPath && bm.hostPath;
2324
+ }
2325
+ return false;
2326
+ })
2327
+ .map((bm) => {
2328
+ const mountOption = bm.mountOption || "rw";
2329
+
2330
+ // Use _hostPath for generated mode, hostPath for existing mode
2331
+ const hostPath = bm.mode === "existing" ? bm.hostPath : bm._hostPath;
2332
+
2333
+ return `${hostPath}:${bm.containerPath}${
2334
+ mountOption === "ro" ? ":ro" : ""
2335
+ }`;
2336
+ });
2337
+
2338
+ if (bindMountVolumes.length > 0) {
2339
+ service.volumes = [...(service.volumes || []), ...bindMountVolumes];
2340
+ }
2341
+ }
2342
+
2343
+ // Mount Docker socket if requested
2344
+ if (component.mountDockerSocket) {
2345
+ const platform = process.platform;
2346
+ let dockerSocketMount;
2347
+
2348
+ if (platform === "win32") {
2349
+ // Windows with Docker Desktop uses named pipe
2350
+ dockerSocketMount = "//var/run/docker.sock:/var/run/docker.sock";
2351
+ } else {
2352
+ // Linux and macOS (Docker Desktop handles the mapping)
2353
+ dockerSocketMount = "/var/run/docker.sock:/var/run/docker.sock";
2354
+ }
2355
+
2356
+ service.volumes = [...(service.volumes || []), dockerSocketMount];
2357
+ }
2358
+
2359
+ // Final cleanup of undefined
2360
+ services[serviceName] = Object.fromEntries(
2361
+ Object.entries(service).filter(([, value]) => value !== undefined),
2362
+ );
2363
+ }
2364
+
2365
+ // Log all services volumes before compose
2366
+ const compose = {
2367
+ name,
2368
+ services,
2369
+ };
2370
+
2371
+ // adding volumes in the root level if defined
2372
+ if (Object.keys(volumes).length > 0) {
2373
+ compose.volumes = volumes;
2374
+ }
2375
+
2376
+ // adding networks in the root level if defined
2377
+ if (Object.keys(networks).length > 0) {
2378
+ compose.networks = networks;
2379
+ }
2380
+
2381
+ const dumpOptions = {
2382
+ noRefs: true,
2383
+ lineWidth: -1,
2384
+ replacer: (key, value) => {
2385
+ if (key === "dockerfile_inline" && typeof value === "string") {
2386
+ return value;
2387
+ }
2388
+ return value;
2389
+ },
2390
+ };
2391
+
2392
+ const yamlStr = yaml.dump(compose, dumpOptions);
2393
+
2394
+ // Debug: Log the proxy service volumes in final YAML
2395
+ fs.writeFileSync(outputPath, yamlStr, "utf8");
2396
+ }
2397
+
2398
+ // Convert Fenwave app nodes to YAML format
2399
+ function convertNodesToYAML(app) {
2400
+ const components = app.nodes
2401
+ .filter((node) => node.type === "component")
2402
+ .map((node) => {
2403
+ const config = node.data.config || {};
2404
+ const component = {
2405
+ name: node.data.name,
2406
+ type: node.data.type,
2407
+ };
2408
+
2409
+ // Build Docker image reference
2410
+ const registry = config.dockerRegistry || "";
2411
+ const repository = config.dockerRepository || "";
2412
+ const image = config.dockerImage || "";
2413
+ const tag = config.dockerTag || "latest";
2414
+
2415
+ if (image) {
2416
+ const fullImage = [registry, repository, image]
2417
+ .filter(Boolean)
2418
+ .join("/");
2419
+ component.docker = {
2420
+ image: `${fullImage}:${tag}`,
2421
+ };
2422
+ }
2423
+
2424
+ // Add ports (multiple ports support)
2425
+ if (config.ports && config.ports.length > 0) {
2426
+ component.ports = config.ports;
2427
+ }
2428
+
2429
+ // Add hostname
2430
+ if (config.hostname) {
2431
+ component.hostname = config.hostname;
2432
+ }
2433
+
2434
+ // Add commands
2435
+ if (config.commands && config.commands.length > 0) {
2436
+ component.command = config.commands;
2437
+ }
2438
+
2439
+ // Add labels
2440
+ if (config.labels && config.labels.length > 0) {
2441
+ component.labels = config.labels.map((l) => `${l.key}=${l.value}`);
2442
+ }
2443
+
2444
+ // Add env_file (with content to write)
2445
+ if (config.envFiles && config.envFiles.length > 0) {
2446
+ component.env_file = config.envFiles
2447
+ .filter((ef) => ef.content && ef.content.trim())
2448
+ .map((ef) => ({
2449
+ name: ef.name || ".env",
2450
+ content: ef.content,
2451
+ }));
2452
+ }
2453
+
2454
+ // Add memory options
2455
+ if (config.memReservation) {
2456
+ component.mem_reservation = config.memReservation;
2457
+ }
2458
+ if (config.memLimit) {
2459
+ component.mem_limit = config.memLimit;
2460
+ }
2461
+
2462
+ // Add hostname
2463
+ if (config.hostname) {
2464
+ component.hostname = config.hostname;
2465
+ }
2466
+
2467
+ // Add commands
2468
+ if (config.commands && config.commands.length > 0) {
2469
+ component.command = config.commands;
2470
+ }
2471
+
2472
+ // Add labels
2473
+ if (config.labels && config.labels.length > 0) {
2474
+ component.labels = config.labels.map((l) => `${l.key}=${l.value}`);
2475
+ }
2476
+
2477
+ // Add env_file (with content to write)
2478
+ if (config.envFiles && config.envFiles.length > 0) {
2479
+ component.env_file = config.envFiles
2480
+ .filter((ef) => ef.content && ef.content.trim())
2481
+ .map((ef) => ({
2482
+ name: ef.name || ".env",
2483
+ content: ef.content,
2484
+ }));
2485
+ }
2486
+
2487
+ // Add memory options
2488
+ if (config.memReservation) {
2489
+ component.mem_reservation = config.memReservation;
2490
+ }
2491
+ if (config.memLimit) {
2492
+ component.mem_limit = config.memLimit;
2493
+ }
2494
+
2495
+ // Add environment variables
2496
+ if (config.env && Object.keys(config.env).length > 0) {
2497
+ component.env = config.env;
2498
+ }
2499
+
2500
+ // Add volumes
2501
+ if (config.volumeMounts && config.volumeMounts.length > 0) {
2502
+ component.volumes = config.volumeMounts.map((v) => ({
2503
+ name: v.name,
2504
+ mount_path: v.mountPath,
2505
+ size: v.size || "1Gi",
2506
+ }));
2507
+ }
2508
+
2509
+ // Add bind mounts with support for both modes
2510
+ if (config.bindMounts && config.bindMounts.length > 0) {
2511
+ component.bindMounts = config.bindMounts.map((bm) => {
2512
+ const baseMount = {
2513
+ containerPath: bm.containerPath,
2514
+ mountOption: bm.mountOption || "rw",
2515
+ mode: bm.mode || "generated",
2516
+ };
2517
+
2518
+ if (bm.mode === "existing" && bm.hostPath) {
2519
+ return {
2520
+ ...baseMount,
2521
+ hostPath: bm.hostPath,
2522
+ };
2523
+ } else if ((bm.mode === "generated" || !bm.mode) && bm.content) {
2524
+ return {
2525
+ ...baseMount,
2526
+ content: bm.content,
2527
+ };
2528
+ }
2529
+
2530
+ // Fallback for legacy bind mounts
2531
+ return {
2532
+ ...baseMount,
2533
+ content: bm.content || "",
2534
+ };
2535
+ });
2536
+ }
2537
+
2538
+ // Add networks
2539
+ if (config.networks && config.networks.length > 0) {
2540
+ component.networks = config.networks;
2541
+ }
2542
+
2543
+ // Add depends_on
2544
+ if (config.dependsOn && config.dependsOn.length > 0) {
2545
+ component.depends_on = config.dependsOn;
2546
+ }
2547
+
2548
+ // Add restart policy
2549
+ if (config.restartPolicy && config.restartPolicy !== "no") {
2550
+ component.restartPolicy = config.restartPolicy;
2551
+ }
2552
+
2553
+ // Add user option
2554
+ if (config.user) {
2555
+ component.user = config.user;
2556
+ }
2557
+
2558
+ // Add mountDockerSocket flag - check both config and data level
2559
+ const hasMountDockerSocket =
2560
+ config.mountDockerSocket || node.data.mountDockerSocket;
2561
+ if (hasMountDockerSocket) {
2562
+ component.mountDockerSocket = true;
2563
+ }
2564
+
2565
+ return component;
2566
+ });
2567
+
2568
+ const appDefinition = {
2569
+ application: {
2570
+ name: app.name.replace(/[^a-z0-9-]/gi, "-").toLowerCase(),
2571
+ version: app.version || "1.0.0",
2572
+ components: components,
2573
+ },
2574
+ };
2575
+
2576
+ const yamlOutput = yaml.dump(appDefinition);
2577
+
2578
+ return yamlOutput;
2579
+ }
2580
+
2581
+ // Run a Fenwave app
2582
+ async function runFenwaveApp(ws, app, yamlContent, requestId, context = {}) {
2583
+ const {
2584
+ isSync = false,
2585
+ isVersionChange = false,
2586
+ targetVersion = null,
2587
+ } = context;
2588
+
2589
+ // Determine if this is a draft or published app
2590
+ const isDraft = app.fwStatus === "draft" || app.status === "draft";
2591
+ const appVersion = app.version || "1.0.0";
2592
+
2593
+ // Create a logger for this app run
2594
+ const logger = createAppLogger(app.name, appVersion);
2595
+
2596
+ try {
2597
+ // Log with appropriate format for draft vs published
2598
+ if (!isSync) {
2599
+ if (isDraft) {
2600
+ logger.info(`Starting draft "${app.name}"...`);
2601
+ } else {
2602
+ logger.info(
2603
+ `Starting application "${app.name}" version ${appVersion}...`,
2604
+ );
2605
+ }
2606
+ }
2607
+
2608
+ // Send run started notification with log file path
2609
+ ws.send(
2610
+ JSON.stringify({
2611
+ type: "runStarted",
2612
+ requestId,
2613
+ logFile: logger.filename,
2614
+ logPath: logger.logPath,
2615
+ }),
2616
+ );
2617
+
2618
+ // Resolve workspace path (check registry or create new)
2619
+ const appId = app.fwId || app.id;
2620
+ const appName = app.name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
2621
+ // For drafts, don't include version in workspace path
2622
+ // Pass 'sync' context to suppress duplicate warning during sync
2623
+ const resolveContext = isSync ? "sync" : "default";
2624
+ const appDownloadDir = isDraft
2625
+ ? resolveWorkspacePathForDraft(appId, appName, resolveContext)
2626
+ : resolveWorkspacePath(appId, appName, appVersion, resolveContext);
2627
+ const isNewWorkspace = !fs.existsSync(appDownloadDir);
2628
+
2629
+ // Parse YAML to extract run config
2630
+ const appDefinition = yaml.load(yamlContent);
2631
+
2632
+ // Add fwId and isDraft to appDefinition for container labeling
2633
+ if (appDefinition.application) {
2634
+ appDefinition.application.fwId = app.fwId || app.id;
2635
+ appDefinition.application.isDraft = isDraft;
2636
+ }
2637
+
2638
+ // Validate components have either an image or build context before proceeding
2639
+ const missingImageComponents = (
2640
+ appDefinition?.application?.components || []
2641
+ )
2642
+ .filter((component) => {
2643
+ const hasBuild = !!(
2644
+ component?.docker?.build && component?.docker?.dockerfile
2645
+ );
2646
+ const hasImage = !!component?.docker?.image;
2647
+ return !hasBuild && !hasImage;
2648
+ })
2649
+ .map((c) => c?.name)
2650
+ .filter(Boolean);
2651
+
2652
+ if (missingImageComponents.length > 0) {
2653
+ // Send a recognizable error that frontend will tailor into a toast
2654
+ const detail =
2655
+ missingImageComponents.length === 1
2656
+ ? `Component "${missingImageComponents[0]}" has neither an image nor a build context specified`
2657
+ : `Components ${missingImageComponents
2658
+ .map((n) => `"${n}"`)
2659
+ .join(", ")} have neither an image nor a build context specified`;
2660
+
2661
+ ws.send(
2662
+ JSON.stringify({
2663
+ type: "error",
2664
+ error: detail,
2665
+ requestId,
2666
+ }),
2667
+ );
2668
+ console.log(chalk.red(`❌ Run aborted: ${detail}`));
2669
+
2670
+ return; // Do not generate docker-compose or proceed further
2671
+ }
2672
+
2673
+ // Now create the workspace directory (validation passed)
2674
+ if (!fs.existsSync(appDownloadDir)) {
2675
+ fs.mkdirSync(appDownloadDir, { recursive: true });
2676
+ // Log workspace creation immediately after it's created
2677
+ console.log(`📁 Created workspace at: ${appDownloadDir}`);
2678
+ }
2679
+
2680
+ const runConfig = appDefinition.application?.deploymentConfig;
2681
+
2682
+ // Transform run config to Docker Compose format
2683
+ let dockerComposeConfig = null;
2684
+ if (runConfig) {
2685
+ dockerComposeConfig = toDockerComposeConfig(runConfig);
2686
+
2687
+ // Generate host environment script if there are host env vars
2688
+ if (Object.keys(dockerComposeConfig.hostEnv).length > 0) {
2689
+ const hostEnvScript = generateHostEnvScript(
2690
+ dockerComposeConfig.hostEnv,
2691
+ );
2692
+ const hostEnvPath = path.join(appDownloadDir, "host-env.sh");
2693
+ fs.writeFileSync(hostEnvPath, hostEnvScript, "utf8");
2694
+ fs.chmodSync(hostEnvPath, "755"); // Make executable
2695
+ }
2696
+
2697
+ // Generate .env file for Docker Compose
2698
+ if (
2699
+ Object.keys(dockerComposeConfig.containerEnv).length > 0 ||
2700
+ Object.keys(dockerComposeConfig.secretRefs).length > 0
2701
+ ) {
2702
+ const envFileContent = generateEnvFile(
2703
+ dockerComposeConfig.containerEnv,
2704
+ dockerComposeConfig.secretRefs,
2705
+ );
2706
+ const envFilePath = path.join(appDownloadDir, ".env");
2707
+ fs.writeFileSync(envFilePath, envFileContent, "utf8");
2708
+ }
2709
+ }
2710
+
2711
+ // Write component-specific env files with content
2712
+ // Collect logs to display in proper order
2713
+ const envFileLogs = [];
2714
+ const bindMountCreatedLogs = [];
2715
+ const bindMountExistingLogs = [];
2716
+
2717
+ if (
2718
+ appDefinition &&
2719
+ appDefinition.application &&
2720
+ appDefinition.application.components
2721
+ ) {
2722
+ for (const component of appDefinition.application.components) {
2723
+ if (component.env_file && Array.isArray(component.env_file)) {
2724
+ for (const envFile of component.env_file) {
2725
+ // Only write if it has content (new format)
2726
+ if (typeof envFile === "object" && envFile.content) {
2727
+ const envFileName = envFile.name || ".env";
2728
+ const envFilePath = path.join(appDownloadDir, envFileName);
2729
+
2730
+ // Check if env file already exists - if so, skip creation
2731
+ if (fs.existsSync(envFilePath)) {
2732
+ envFileLogs.push(`✅ Env file already exists: ${envFileName}`);
2733
+ } else {
2734
+ fs.writeFileSync(envFilePath, envFile.content, "utf8");
2735
+ envFileLogs.push(`📝 Created env file: ${envFileName}`);
2736
+ }
2737
+ }
2738
+ }
2739
+ }
2740
+
2741
+ // Write bind mount files with content or prepare existing paths
2742
+ if (component.bindMounts && Array.isArray(component.bindMounts)) {
2743
+ for (const bindMount of component.bindMounts) {
2744
+ if (!bindMount.containerPath) {
2745
+ console.warn(`⚠️ Skipping bind mount without containerPath`);
2746
+ continue;
2747
+ }
2748
+
2749
+ const containerPath = bindMount.containerPath;
2750
+ const mode = bindMount.mode || "generated";
2751
+
2752
+ // Handle existing host path mode
2753
+ if (mode === "existing") {
2754
+ if (!bindMount.hostPath) {
2755
+ console.warn(
2756
+ `⚠️ Skipping bind mount in 'existing' mode without hostPath: ${containerPath}`,
2757
+ );
2758
+ continue;
2759
+ }
2760
+
2761
+ // Use the user-provided host path directly
2762
+ bindMount._hostPath = bindMount.hostPath;
2763
+ bindMountExistingLogs.push(
2764
+ `🔧 Using existing host path for ${containerPath} : ${bindMount.hostPath}`,
2765
+ );
2766
+ continue;
2767
+ }
2768
+
2769
+ // Handle generated content mode
2770
+ if (!bindMount.content) {
2771
+ console.warn(
2772
+ `⚠️ Skipping bind mount in 'generated' mode without content: ${containerPath}`,
2773
+ );
2774
+ continue;
2775
+ }
2776
+
2777
+ // Skip special system paths that should bind to host directly
2778
+ const systemPaths = [
2779
+ "/var/run/docker.sock",
2780
+ "/var/run/docker.sock.raw",
2781
+ ];
2782
+ if (systemPaths.includes(containerPath)) {
2783
+ console.log(
2784
+ `⚠️ Skipping file creation for system path: ${containerPath} (will bind to host)`,
2785
+ );
2786
+ bindMount._hostPath = containerPath;
2787
+ continue;
2788
+ }
2789
+
2790
+ // Determine if this is a file or directory based on the path
2791
+ const isDirectory =
2792
+ !path.extname(containerPath) || containerPath.endsWith("/");
2793
+ const baseName = path.basename(containerPath.replace(/\/$/, ""));
2794
+
2795
+ // Check if an existing bind mount with the same prefix already exists
2796
+ const existingFiles = fs.readdirSync(appDownloadDir);
2797
+ const existingMatch = existingFiles.find((file) => {
2798
+ // Match pattern: baseName.suffix
2799
+ const regex = new RegExp(`^${baseName}\\.[a-z0-9]+$`, "i");
2800
+ return regex.test(file);
2801
+ });
2802
+
2803
+ let hostPath;
2804
+
2805
+ if (existingMatch) {
2806
+ // Reuse existing bind mount
2807
+ const existingPath = path.join(appDownloadDir, existingMatch);
2808
+ const existingStat = fs.statSync(existingPath);
2809
+
2810
+ if (isDirectory && existingStat.isDirectory()) {
2811
+ // Check and update the file inside the directory
2812
+ const fileName = `${baseName}.toml`;
2813
+ const existingFilePath = path.join(existingPath, fileName);
2814
+
2815
+ try {
2816
+ // Check if content needs updating
2817
+ let needsUpdate = true;
2818
+ if (fs.existsSync(existingFilePath)) {
2819
+ const existingContent = fs.readFileSync(
2820
+ existingFilePath,
2821
+ "utf8",
2822
+ );
2823
+ needsUpdate = existingContent !== bindMount.content;
2824
+ }
2825
+
2826
+ if (needsUpdate) {
2827
+ fs.writeFileSync(
2828
+ existingFilePath,
2829
+ bindMount.content,
2830
+ "utf8",
2831
+ );
2832
+ bindMountCreatedLogs.push(
2833
+ `📎 Updated bind mount directory: ${existingMatch}/ with file: ${fileName}`,
2834
+ );
2835
+ } else {
2836
+ bindMountCreatedLogs.push(
2837
+ `📎 Bind mount directory already up-to-date: ${existingMatch}/`,
2838
+ );
2839
+ }
2840
+ } catch (err) {
2841
+ // Permission denied ('ro' mount)
2842
+ console.log(
2843
+ `⚠️ Cannot update ${existingMatch}/ (${
2844
+ err.code || "permission denied"
2845
+ }), keeping existing content`,
2846
+ );
2847
+ }
2848
+
2849
+ hostPath = `./${existingMatch}`;
2850
+ } else if (!isDirectory && existingStat.isFile()) {
2851
+ // Update the existing file
2852
+ try {
2853
+ let needsUpdate = true;
2854
+ const existingContent = fs.readFileSync(existingPath, "utf8");
2855
+ needsUpdate = existingContent !== bindMount.content;
2856
+
2857
+ if (needsUpdate) {
2858
+ fs.writeFileSync(existingPath, bindMount.content, "utf8");
2859
+ bindMountCreatedLogs.push(
2860
+ `📎 Updated bind mount file: ${existingMatch}`,
2861
+ );
2862
+ } else {
2863
+ bindMountCreatedLogs.push(
2864
+ `📎 Bind mount file already up-to-date: ${existingMatch}`,
2865
+ );
2866
+ }
2867
+ } catch (err) {
2868
+ console.log(
2869
+ `⚠️ Cannot update ${existingMatch} (${
2870
+ err.code || "permission denied"
2871
+ }), keeping existing content`,
2872
+ );
2873
+ }
2874
+
2875
+ hostPath = `./${existingMatch}`;
2876
+ } else {
2877
+ // Type mismatch, create new
2878
+ const randomSuffix = Math.random().toString(36).substring(2, 8);
2879
+ const result = createNewBindMount(
2880
+ appDownloadDir,
2881
+ bindMount,
2882
+ baseName,
2883
+ isDirectory,
2884
+ randomSuffix,
2885
+ );
2886
+ hostPath = result.hostPath;
2887
+ bindMountCreatedLogs.push(result.log);
2888
+ }
2889
+ } else {
2890
+ // No existing match, create new bind mount
2891
+ const randomSuffix = Math.random().toString(36).substring(2, 8);
2892
+ const result = createNewBindMount(
2893
+ appDownloadDir,
2894
+ bindMount,
2895
+ baseName,
2896
+ isDirectory,
2897
+ randomSuffix,
2898
+ );
2899
+ hostPath = result.hostPath;
2900
+ bindMountCreatedLogs.push(result.log);
2901
+ }
2902
+
2903
+ // Store the generated host path for docker-compose generation (relative path)
2904
+ bindMount._hostPath = hostPath;
2905
+ }
2906
+ }
2907
+ }
2908
+ }
2909
+
2910
+ // Load setup wizard variables and substitute in app definition
2911
+ logger.info(
2912
+ `Loading setup wizard progress for appId=${appId}, version=${appVersion}`,
2913
+ );
2914
+ const setupProgress = setupTasks.loadSetupProgress(appId, appVersion);
2915
+ let finalAppDefinition = appDefinition;
2916
+ if (setupProgress) {
2917
+ if (
2918
+ setupProgress.variables &&
2919
+ Object.keys(setupProgress.variables).length > 0
2920
+ ) {
2921
+ logger.info(`📋 Setup wizard variables:`);
2922
+ for (const [key, value] of Object.entries(setupProgress.variables)) {
2923
+ logger.info(` ${key} = ${value}`);
2924
+ }
2925
+ finalAppDefinition = substituteSetupVariables(
2926
+ appDefinition,
2927
+ setupProgress.variables,
2928
+ );
2929
+ logger.success(
2930
+ `✅ Applied ${Object.keys(setupProgress.variables).length} setup variable(s)`,
2931
+ );
2932
+ }
2933
+ } else {
2934
+ logger.info(`No setup wizard configuration for this app`);
2935
+ }
2936
+
2937
+ // Generate Docker Compose file directly
2938
+ const dockerComposePath = path.join(appDownloadDir, "docker-compose.yml");
2939
+ generateDockerCompose(
2940
+ finalAppDefinition,
2941
+ dockerComposePath,
2942
+ dockerComposeConfig,
2943
+ );
2944
+ console.log(`🐳 Docker Compose downloaded to: ${dockerComposePath}`);
2945
+
2946
+ // Now print collected logs in order
2947
+ for (const log of envFileLogs) {
2948
+ console.log(log);
2949
+ }
2950
+ for (const log of bindMountCreatedLogs) {
2951
+ console.log(log);
2952
+ }
2953
+ for (const log of bindMountExistingLogs) {
2954
+ console.log(log);
2955
+ }
2956
+
2957
+ // Send docker compose generated notification
2958
+ ws.send(
2959
+ JSON.stringify({
2960
+ type: "dockerComposeGenerated",
2961
+ dockerComposePath,
2962
+ requestId,
2963
+ }),
2964
+ );
2965
+
2966
+ // Build docker-compose command
2967
+ // If there's a host-env.sh, source it before running docker-compose
2968
+ let dockerComposeCommand;
2969
+ if (
2970
+ dockerComposeConfig &&
2971
+ Object.keys(dockerComposeConfig.hostEnv).length > 0
2972
+ ) {
2973
+ dockerComposeCommand = `cd "${appDownloadDir}" && source ./host-env.sh && docker compose up -d`;
2974
+ } else {
2975
+ dockerComposeCommand = `cd "${appDownloadDir}" && docker compose up -d`;
2976
+ }
2977
+
2978
+ if (isSync) {
2979
+ if (isDraft) {
2980
+ logger.success(`Draft "${app.name}" synced successfully!`);
2981
+ } else {
2982
+ logger.success(
2983
+ `Application "${app.name} (${appVersion})" synced successfully!`,
2984
+ );
2985
+ }
2986
+ logger.info(`Starting containers...`);
2987
+ }
2988
+
2989
+ logger.info(`Starting containers with docker-compose...`);
2990
+ logger.info(`Working directory: ${appDownloadDir}`);
2991
+ logger.info(`Command: docker compose up -d`);
2992
+
2993
+ // Notify DevApp that docker-compose is starting
2994
+ ws.send(
2995
+ JSON.stringify({
2996
+ type: "runProgress",
2997
+ message:
2998
+ "Starting containers... This may take a while if images need to be pulled.",
2999
+ phase: "docker-compose",
3000
+ logFile: logger.filename,
3001
+ requestId,
3002
+ }),
3003
+ );
3004
+
3005
+ // Use spawn for real-time logging of docker-compose output
3006
+ const spawnProcess = spawn("sh", ["-c", dockerComposeCommand], {
3007
+ cwd: appDownloadDir,
3008
+ env: { ...process.env },
3009
+ });
3010
+
3011
+ let stdoutBuffer = "";
3012
+ let stderrBuffer = "";
3013
+
3014
+ // Capture stdout in real-time
3015
+ spawnProcess.stdout.on("data", (data) => {
3016
+ const output = data.toString();
3017
+ stdoutBuffer += output;
3018
+ logger.docker(output, "stdout");
3019
+ });
3020
+
3021
+ // Capture stderr in real-time (docker-compose outputs progress to stderr)
3022
+ spawnProcess.stderr.on("data", (data) => {
3023
+ const output = data.toString();
3024
+ stderrBuffer += output;
3025
+ logger.docker(output, "stderr");
3026
+ });
3027
+
3028
+ spawnProcess.on("close", async (code) => {
3029
+ if (code !== 0) {
3030
+ // Log error
3031
+ logger.error(`Docker Compose exited with code ${code}`);
3032
+ logger.complete(false, stderrBuffer || `Exit code: ${code}`);
3033
+
3034
+ // Clean up workspace folder if run failed and it was newly created
3035
+ if (isNewWorkspace && fs.existsSync(appDownloadDir)) {
3036
+ try {
3037
+ fs.rmSync(appDownloadDir, { recursive: true, force: true });
3038
+ logger.info(`Cleaned up failed workspace: ${appDownloadDir}`);
3039
+ } catch (cleanupErr) {
3040
+ logger.warn(`Failed to clean up workspace: ${cleanupErr.message}`);
3041
+ }
3042
+ }
3043
+
3044
+ // Send detailed error to DevApp with log file
3045
+ ws.send(
3046
+ JSON.stringify({
3047
+ type: "error",
3048
+ error: `Failed to run "${app.name}": Docker Compose exited with code ${code}`,
3049
+ details: {
3050
+ command: "docker compose up -d",
3051
+ stderr: stderrBuffer,
3052
+ stdout: stdoutBuffer,
3053
+ workspacePath: appDownloadDir,
3054
+ exitCode: code,
3055
+ },
3056
+ logFile: logger.filename,
3057
+ logPath: logger.logPath,
3058
+ requestId,
3059
+ }),
3060
+ );
3061
+ return;
3062
+ }
3063
+
3064
+ // Wait a moment for containers to fully start
3065
+ await new Promise((resolve) => setTimeout(resolve, 2000));
3066
+
3067
+ // Check container status and count
3068
+ let containerCount = 0;
3069
+ let runningCount = 0;
3070
+ let exitedContainers = [];
3071
+
3072
+ try {
3073
+ const containers = await docker.listContainers({ all: true });
3074
+ const allAppContainers = filterContainersByApp(
3075
+ containers,
3076
+ app.fwId || app.id,
3077
+ isDraft,
3078
+ appVersion,
3079
+ );
3080
+
3081
+ containerCount = allAppContainers.length;
3082
+
3083
+ for (const container of allAppContainers) {
3084
+ const containerName = container.Names[0].replace(/^\//, "");
3085
+
3086
+ if (container.State === "running") {
3087
+ runningCount++;
3088
+ logger.info(`Container ${containerName}: running`);
3089
+ } else if (container.State === "exited") {
3090
+ exitedContainers.push(containerName);
3091
+ logger.warn(
3092
+ `Container ${containerName}: exited (status: ${container.Status})`,
3093
+ );
3094
+ } else {
3095
+ logger.info(`Container ${containerName}: ${container.State}`);
3096
+ }
3097
+ }
3098
+ } catch (countError) {
3099
+ logger.warn(`Error checking containers: ${countError.message}`);
3100
+ }
3101
+
3102
+ // Log summary
3103
+ logger.info(
3104
+ `Container summary: ${runningCount}/${containerCount} running`,
3105
+ );
3106
+
3107
+ if (exitedContainers.length > 0) {
3108
+ logger.warn(`Exited containers: ${exitedContainers.join(", ")}`);
3109
+ }
3110
+
3111
+ // Log success (even if some containers exited - docker-compose up succeeded)
3112
+ if (isDraft) {
3113
+ logger.success(`Draft "${app.name}" started successfully!`);
3114
+ } else {
3115
+ logger.success(
3116
+ `Application "${app.name} (${app.version})" started successfully!`,
3117
+ );
3118
+ }
3119
+ logger.info(
3120
+ `Containers: ${runningCount} running, ${exitedContainers.length} exited`,
3121
+ );
3122
+ logger.complete(
3123
+ true,
3124
+ `${runningCount}/${containerCount} container(s) running`,
3125
+ );
3126
+
3127
+ // Create workspace metadata on first successful run, or update if exists
3128
+ if (!isValidWorkspace(appDownloadDir)) {
3129
+ createWorkspaceMetadata(appDownloadDir, appId, app.name);
3130
+ // Register with "draft" key for drafts, or version for published apps
3131
+ const registryVersion = isDraft ? "draft" : appVersion;
3132
+ registerWorkspace(appId, app.name, registryVersion, appDownloadDir);
3133
+ } else {
3134
+ updateWorkspaceMetadata(appDownloadDir);
3135
+ }
3136
+
3137
+ // Send success notification (with warning if containers exited)
3138
+ // If this is a version change, send versionChangeCompleted instead of runCompleted
3139
+ if (isVersionChange) {
3140
+ ws.send(
3141
+ JSON.stringify({
3142
+ type: "versionChangeCompleted",
3143
+ requestId,
3144
+ app: {
3145
+ ...app,
3146
+ status: runningCount > 0 ? "running" : "stopped",
3147
+ containers: containerCount,
3148
+ },
3149
+ message: `Successfully changed to version ${targetVersion || app.version}`,
3150
+ progress: 100,
3151
+ }),
3152
+ );
3153
+ } else {
3154
+ ws.send(
3155
+ JSON.stringify({
3156
+ type: "runCompleted",
3157
+ app: {
3158
+ ...app,
3159
+ status: runningCount > 0 ? "running" : "stopped",
3160
+ containers: containerCount,
3161
+ },
3162
+ message:
3163
+ exitedContainers.length > 0
3164
+ ? `Application started with warnings: ${exitedContainers.length} container(s) exited`
3165
+ : "Application ran successfully!",
3166
+ stdout: stdoutBuffer || "Run completed",
3167
+ stderr: stderrBuffer || "",
3168
+ downloadPath: appDownloadDir,
3169
+ logFile: logger.filename,
3170
+ logPath: logger.logPath,
3171
+ exitedContainers: exitedContainers,
3172
+ requestId,
3173
+ }),
3174
+ );
3175
+ }
3176
+ });
3177
+
3178
+ spawnProcess.on("error", (error) => {
3179
+ logger.error(`Failed to spawn docker-compose: ${error.message}`);
3180
+ logger.complete(false, error.message);
3181
+
3182
+ // Send appropriate error event based on context
3183
+ if (isVersionChange) {
3184
+ ws.send(
3185
+ JSON.stringify({
3186
+ type: "versionChangeFailed",
3187
+ requestId,
3188
+ error: `Failed to run "${app.name}": ${error.message}`,
3189
+ }),
3190
+ );
3191
+ } else {
3192
+ ws.send(
3193
+ JSON.stringify({
3194
+ type: "error",
3195
+ error: `Failed to run "${app.name}": ${error.message}`,
3196
+ logFile: logger.filename,
3197
+ logPath: logger.logPath,
3198
+ requestId,
3199
+ }),
3200
+ );
3201
+ }
3202
+ });
3203
+ } catch (error) {
3204
+ logger.error(`Failed to run: ${error.message}`);
3205
+ if (error.stack) {
3206
+ logger.docker(error.stack, "stderr");
3207
+ }
3208
+ logger.complete(false, error.message);
3209
+
3210
+ // Send appropriate error event based on context
3211
+ if (isVersionChange) {
3212
+ ws.send(
3213
+ JSON.stringify({
3214
+ type: "versionChangeFailed",
3215
+ requestId,
3216
+ error: `Failed to run "${app.name}": ${error.message}`,
3217
+ }),
3218
+ );
3219
+ } else {
3220
+ ws.send(
3221
+ JSON.stringify({
3222
+ type: "error",
3223
+ error: `Failed to run "${app.name}": ${error.message}`,
3224
+ details: {
3225
+ stack: error.stack,
3226
+ },
3227
+ logFile: logger.filename,
3228
+ logPath: logger.logPath,
3229
+ requestId,
3230
+ }),
3231
+ );
3232
+ }
3233
+ }
3234
+ }
3235
+
3236
+ /**
3237
+ * Validate Docker Compose configuration using real docker-compose CLI
3238
+ * This provides accurate validation against the local environment
3239
+ */
3240
+ async function handleValidateCompose(ws, payload) {
3241
+ const { composeYaml, envVars, requestId } = payload;
3242
+
3243
+ console.log("🔍 Validating Docker Compose configuration...");
3244
+
3245
+ try {
3246
+ // Create temporary directory for validation using system temp directory
3247
+ const tempDir = path.join(os.tmpdir(), `fenwave-validate-${Date.now()}`);
3248
+ fs.mkdirSync(tempDir, { recursive: true });
3249
+
3250
+ const composePath = path.join(tempDir, "docker-compose.yml");
3251
+ const envPath = path.join(tempDir, ".env");
3252
+
3253
+ try {
3254
+ // Write docker-compose.yml
3255
+ fs.writeFileSync(composePath, composeYaml, "utf-8");
3256
+ console.log(`✅ Written docker-compose.yml to ${composePath}`);
3257
+
3258
+ // Write .env file if provided
3259
+ if (envVars && Object.keys(envVars).length > 0) {
3260
+ const envContent = Object.entries(envVars)
3261
+ .map(([key, value]) => `${key}=${value}`)
3262
+ .join("\n");
3263
+ fs.writeFileSync(envPath, envContent, "utf-8");
3264
+ console.log(
3265
+ `✅ Written .env file with ${Object.keys(envVars).length} variables`,
3266
+ );
3267
+ }
3268
+
3269
+ // Run docker-compose config to validate
3270
+ const command = `cd "${tempDir}" && docker compose config --quiet`;
3271
+
3272
+ await new Promise((resolve, reject) => {
3273
+ exec(command, { timeout: 10000 }, (error, stdout, stderr) => {
3274
+ if (error) {
3275
+ // Docker Compose found errors
3276
+ console.error(
3277
+ chalk.red(
3278
+ "❌ Docker Compose validation failed:",
3279
+ stderr || error.message,
3280
+ ),
3281
+ );
3282
+
3283
+ // Send validation errors back to client
3284
+ ws.send(
3285
+ JSON.stringify({
3286
+ type: "validateCompose_result",
3287
+ requestId,
3288
+ data: {
3289
+ valid: false,
3290
+ errors: [stderr || error.message],
3291
+ warnings: [],
3292
+ },
3293
+ }),
3294
+ );
3295
+
3296
+ reject(error);
3297
+ } else {
3298
+ console.log("✅ Docker Compose validation passed");
3299
+
3300
+ // Send success result
3301
+ ws.send(
3302
+ JSON.stringify({
3303
+ type: "validateCompose_result",
3304
+ requestId,
3305
+ data: {
3306
+ valid: true,
3307
+ errors: [],
3308
+ warnings: [],
3309
+ },
3310
+ }),
3311
+ );
3312
+
3313
+ resolve();
3314
+ }
3315
+ });
3316
+ });
3317
+ } finally {
3318
+ // Cleanup temporary files
3319
+ try {
3320
+ if (fs.existsSync(composePath)) {
3321
+ fs.unlinkSync(composePath);
3322
+ }
3323
+ if (fs.existsSync(envPath)) {
3324
+ fs.unlinkSync(envPath);
3325
+ }
3326
+ if (fs.existsSync(tempDir)) {
3327
+ fs.rmdirSync(tempDir);
3328
+ }
3329
+ console.log("🧹 Cleaned up temporary validation files");
3330
+ } catch (cleanupError) {
3331
+ console.warn("⚠️ Failed to cleanup temp files:", cleanupError.message);
3332
+ }
3333
+ }
3334
+ } catch (error) {
3335
+ console.error(chalk.red("❌ Validation error:", error));
3336
+ ws.send(
3337
+ JSON.stringify({
3338
+ type: "error",
3339
+ error: `Validation failed: ${error.message}`,
3340
+ requestId,
3341
+ }),
3342
+ );
3343
+ }
3344
+ }
3345
+
3346
+ /**
3347
+ * Sync applications:
3348
+ * - Stops & removes existing containers, fetches latest app definition from Fenwave,
3349
+ * regenerates docker-compose and runs the app (recreate all containers).
3350
+ * - Preserves the run state (running/stopped) from before sync.
3351
+ * - Uses non-destructive backup approach for docker-compose.yml
3352
+ */
3353
+ async function handleSyncApp(ws, payload = {}) {
3354
+ const { fwId, appData, id, requestId, keepRunning, changeId } = payload;
3355
+ try {
3356
+ // Extract fwId from id if needed
3357
+ const actualFwId =
3358
+ fwId ||
3359
+ (id &&
3360
+ (id.startsWith("fw-")
3361
+ ? id.replace("fw-", "")
3362
+ : id.startsWith("backstage-")
3363
+ ? id.replace("backstage-", "")
3364
+ : id));
3365
+
3366
+ if (!actualFwId) throw new Error("fwId or id required for sync");
3367
+
3368
+ ws.send(
3369
+ JSON.stringify({
3370
+ type: "syncProgress",
3371
+ requestId,
3372
+ message: "Starting sync...",
3373
+ progress: 10,
3374
+ }),
3375
+ );
3376
+
3377
+ let latestApp;
3378
+
3379
+ // If appData is provided (from App Builder), use it directly
3380
+ if (appData) {
3381
+ ws.send(
3382
+ JSON.stringify({
3383
+ type: "syncProgress",
3384
+ requestId,
3385
+ message: "Processing app blueprint...",
3386
+ progress: 20,
3387
+ }),
3388
+ );
3389
+
3390
+ latestApp = {
3391
+ id: `fw-${actualFwId}`,
3392
+ fwId: actualFwId,
3393
+ name: appData.name,
3394
+ nodes: appData.nodes,
3395
+ edges: appData.edges,
3396
+ description: appData.description,
3397
+ version: appData.version,
3398
+ status: appData.status,
3399
+ source: "fenwave",
3400
+ };
3401
+ } else {
3402
+ // Fetch from Fenwave API (called from DevApp)
3403
+ ws.send(
3404
+ JSON.stringify({
3405
+ type: "syncProgress",
3406
+ requestId,
3407
+ message: "Fetching app info from Fenwave...",
3408
+ progress: 20,
3409
+ }),
3410
+ );
3411
+
3412
+ const session = loadSession();
3413
+ if (!session || !session.token) {
3414
+ throw new Error("No valid session found. Please run: fenwave login");
3415
+ }
3416
+
3417
+ // Fetch app metadata first to get the app name
3418
+ const backendUrl = getBackendUrl();
3419
+ const appUrl = `${backendUrl}/api/app-builder/applications/${actualFwId}`;
3420
+ const appResponse = await axios.get(appUrl, {
3421
+ headers: {
3422
+ "Content-Type": "application/json",
3423
+ Authorization: `Bearer ${session.token}`,
3424
+ },
3425
+ timeout: 10000,
3426
+ });
3427
+
3428
+ const fetchedApp = appResponse.data;
3429
+
3430
+ // Now check for existing containers using the ACTUAL app name
3431
+ const normalizedAppName = fetchedApp.name
3432
+ .replace(/[^a-z0-9-]/gi, "-")
3433
+ .toLowerCase();
3434
+
3435
+ const containers = await docker.listContainers({ all: true });
3436
+ const existingContainers = containers.filter((container) => {
3437
+ const containerName = container.Names[0].replace(/^\//, "");
3438
+ const projectLabel = container.Labels["com.docker.compose.project"];
3439
+ return (
3440
+ containerName.startsWith(normalizedAppName) ||
3441
+ projectLabel === normalizedAppName ||
3442
+ container.Labels["app"] === normalizedAppName
3443
+ );
3444
+ });
3445
+
3446
+ let versionToFetch = null;
3447
+ if (
3448
+ existingContainers.length > 0 &&
3449
+ existingContainers[0].Labels["app.version"]
3450
+ ) {
3451
+ versionToFetch = existingContainers[0].Labels["app.version"];
3452
+ }
3453
+
3454
+ // Fetch versions separately if we need a specific version
3455
+ let allVersions = [];
3456
+ if (versionToFetch) {
3457
+ try {
3458
+ const versionsUrl = `${backendUrl}/api/app-builder/applications/${actualFwId}/versions`;
3459
+ const versionsResponse = await axios.get(versionsUrl, {
3460
+ headers: {
3461
+ "Content-Type": "application/json",
3462
+ Authorization: `Bearer ${session.token}`,
3463
+ },
3464
+ timeout: 10000,
3465
+ });
3466
+ allVersions = versionsResponse.data || [];
3467
+ } catch (err) {
3468
+ console.warn(`Failed to fetch versions: ${err.message}`);
3469
+ }
3470
+ }
3471
+
3472
+ // If we have a specific version to preserve, use that version's blueprint from the versions array
3473
+ let versionData = null;
3474
+ if (versionToFetch && allVersions.length > 0) {
3475
+ versionData = allVersions.find((v) => v.version === versionToFetch);
3476
+
3477
+ if (versionData) {
3478
+ ws.send(
3479
+ JSON.stringify({
3480
+ type: "syncProgress",
3481
+ requestId,
3482
+ message: `Using version ${versionToFetch} blueprint...`,
3483
+ progress: 25,
3484
+ }),
3485
+ );
3486
+ }
3487
+ }
3488
+
3489
+ // Use version-specific data if available, otherwise use active version
3490
+ const blueprintSource =
3491
+ versionData || fetchedApp.activeVersion || fetchedApp;
3492
+ const finalVersion =
3493
+ versionToFetch ||
3494
+ blueprintSource.version ||
3495
+ fetchedApp.activeVersion?.version ||
3496
+ "1.0.0";
3497
+
3498
+ latestApp = {
3499
+ id: `fw-${actualFwId}`,
3500
+ fwId: actualFwId,
3501
+ name: fetchedApp.name,
3502
+ nodes: blueprintSource.nodes || [],
3503
+ edges: blueprintSource.edges || [],
3504
+ description: blueprintSource.description || "",
3505
+ version: finalVersion,
3506
+ status:
3507
+ fetchedApp.status || fetchedApp.activeVersion?.status || "draft",
3508
+ source: "fenwave",
3509
+ };
3510
+ }
3511
+
3512
+ // Resolve workspace path (use registered or create new)
3513
+ const appId = latestApp.fwId || latestApp.id;
3514
+ const appVersion = latestApp.version || "1.0.0";
3515
+ const isDraft = latestApp.status === "draft";
3516
+
3517
+ if (isDraft) {
3518
+ console.log(`🚀 Syncing draft "${latestApp.name}"...`);
3519
+ } else {
3520
+ console.log(
3521
+ `🚀 Syncing application "${latestApp.name} (${appVersion})"...`,
3522
+ );
3523
+ }
3524
+
3525
+ const appDownloadDir = isDraft
3526
+ ? resolveWorkspacePathForDraft(appId, latestApp.name)
3527
+ : resolveWorkspacePath(appId, latestApp.name, appVersion);
3528
+
3529
+ // Backup existing docker-compose.yml before regenerating
3530
+ ws.send(
3531
+ JSON.stringify({
3532
+ type: "syncProgress",
3533
+ requestId,
3534
+ message: "Backing up existing configuration...",
3535
+ progress: 28,
3536
+ }),
3537
+ );
3538
+
3539
+ if (fs.existsSync(appDownloadDir)) {
3540
+ backupDockerCompose(appDownloadDir);
3541
+ }
3542
+
3543
+ // Normalize app name for container matching
3544
+ const appName = latestApp.name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
3545
+
3546
+ // Detect existing containers
3547
+ const containers = await docker.listContainers({ all: true });
3548
+ const appContainers = containers.filter((container) => {
3549
+ const containerName = container.Names[0].replace(/^\//, "");
3550
+ const projectLabel = container.Labels["com.docker.compose.project"];
3551
+ return (
3552
+ containerName.startsWith(appName) ||
3553
+ projectLabel === appName ||
3554
+ container.Labels["app"] === appName
3555
+ );
3556
+ });
3557
+
3558
+ // Record whether it was running
3559
+ const wasRunning = appContainers.some((c) => c.State === "running");
3560
+
3561
+ ws.send(
3562
+ JSON.stringify({
3563
+ type: "syncProgress",
3564
+ requestId,
3565
+ message: `Stopping ${appContainers.length} containers...`,
3566
+ progress: 30,
3567
+ }),
3568
+ );
3569
+
3570
+ // Stop running containers
3571
+ for (const c of appContainers) {
3572
+ try {
3573
+ if (c.State === "running") {
3574
+ await docker.getContainer(c.Id).stop();
3575
+ }
3576
+ } catch (err) {
3577
+ console.warn("Error stopping container", c.Id, err.message);
3578
+ }
3579
+ }
3580
+
3581
+ ws.send(
3582
+ JSON.stringify({
3583
+ type: "syncProgress",
3584
+ requestId,
3585
+ message: "Removing old containers...",
3586
+ progress: 50,
3587
+ }),
3588
+ );
3589
+
3590
+ // Remove containers
3591
+ for (const c of appContainers) {
3592
+ try {
3593
+ await docker.getContainer(c.Id).remove({ force: true });
3594
+ } catch (err) {
3595
+ console.warn("Error removing container", c.Id, err.message);
3596
+ }
3597
+ }
3598
+
3599
+ // Prepare YAML for the latest version and run
3600
+ const yamlContent = convertNodesToYAML(latestApp);
3601
+
3602
+ ws.send(
3603
+ JSON.stringify({
3604
+ type: "syncProgress",
3605
+ requestId,
3606
+ message: "Regenerating compose and creating containers...",
3607
+ progress: 70,
3608
+ }),
3609
+ );
3610
+
3611
+ // Reuse runFenwaveApp flow to create docker-compose and run
3612
+ await runFenwaveApp(ws, latestApp, yamlContent, requestId, {
3613
+ isSync: true,
3614
+ });
3615
+
3616
+ ws.send(
3617
+ JSON.stringify({
3618
+ type: "syncProgress",
3619
+ requestId,
3620
+ message: "Restoring run state...",
3621
+ progress: 90,
3622
+ }),
3623
+ );
3624
+
3625
+ // If original was stopped AND we're not doing auto-sync-before-start, stop the newly created containers
3626
+ // keepRunning flag indicates this sync is part of auto-sync-then-start flow
3627
+ if (!wasRunning && !keepRunning) {
3628
+ // Wait a moment for containers to fully start before stopping them
3629
+ await new Promise((resolve) => setTimeout(resolve, 2000));
3630
+
3631
+ const postContainers = await docker.listContainers({ all: true });
3632
+ const newAppContainers = postContainers.filter((container) => {
3633
+ const containerName = container.Names[0].replace(/^\//, "");
3634
+ const projectLabel = container.Labels["com.docker.compose.project"];
3635
+ return (
3636
+ containerName.startsWith(appName) ||
3637
+ projectLabel === appName ||
3638
+ container.Labels["app"] === appName
3639
+ );
3640
+ });
3641
+ for (const c of newAppContainers) {
3642
+ if (c.State === "running") {
3643
+ try {
3644
+ await docker.getContainer(c.Id).stop();
3645
+ } catch (stopErr) {
3646
+ console.warn(
3647
+ "Failed to stop container after recreate",
3648
+ c.Id,
3649
+ stopErr.message,
3650
+ );
3651
+ }
3652
+ }
3653
+ }
3654
+ ws.send(
3655
+ JSON.stringify({
3656
+ type: "syncProgress",
3657
+ requestId,
3658
+ message: "Preserved stopped state",
3659
+ progress: 95,
3660
+ }),
3661
+ );
3662
+ }
3663
+
3664
+ // Acknowledge the event to prevent it from being broadcasted again
3665
+ if (changeId) {
3666
+ await acknowledgeEvent(null, changeId);
3667
+ }
3668
+
3669
+ // Final success - determine final state
3670
+ const finalState = keepRunning || wasRunning ? "running" : "stopped";
3671
+ ws.send(
3672
+ JSON.stringify({
3673
+ type: "syncCompleted",
3674
+ requestId,
3675
+ app: { ...latestApp, status: finalState },
3676
+ message: "Sync completed successfully",
3677
+ progress: 100,
3678
+ }),
3679
+ );
3680
+
3681
+ // Acknowledge the event after successful sync
3682
+ if (payload.eventId) {
3683
+ const ackSuccess = await acknowledgeEvent(
3684
+ payload.eventId,
3685
+ payload.changeId,
3686
+ );
3687
+ if (ackSuccess) {
3688
+ console.log(`✅ Event ${payload.eventId} acknowledged successfully`);
3689
+ } else {
3690
+ console.warn(`⚠️ Failed to acknowledge event ${payload.eventId}`);
3691
+ }
3692
+ }
3693
+ } catch (error) {
3694
+ console.error(chalk.red("❌ Sync failed:", error.message || error));
3695
+ ws.send(
3696
+ JSON.stringify({
3697
+ type: "syncFailed",
3698
+ requestId,
3699
+ error: error.message || String(error),
3700
+ }),
3701
+ );
3702
+ }
3703
+ }
3704
+
3705
+ /**
3706
+ * Change app version:
3707
+ * - Stops & removes existing containers
3708
+ * - Fetches the specified version from Fenwave
3709
+ * - Regenerates docker-compose and runs the app with the selected version
3710
+ */
3711
+ async function handleChangeVersion(ws, payload = {}) {
3712
+ const { fwId, id, targetVersion, requestId } = payload;
3713
+
3714
+ try {
3715
+ // Extract fwId from id if needed
3716
+ const actualFwId =
3717
+ fwId ||
3718
+ (id &&
3719
+ (id.startsWith("fw-")
3720
+ ? id.replace("fw-", "")
3721
+ : id.startsWith("backstage-")
3722
+ ? id.replace("backstage-", "")
3723
+ : id));
3724
+
3725
+ if (!actualFwId) {
3726
+ throw new Error("fwId or id required for version change");
3727
+ }
3728
+
3729
+ if (!targetVersion) {
3730
+ throw new Error("targetVersion is required");
3731
+ }
3732
+
3733
+ ws.send(
3734
+ JSON.stringify({
3735
+ type: "versionChangeProgress",
3736
+ requestId,
3737
+ message: "Starting version change...",
3738
+ progress: 10,
3739
+ }),
3740
+ );
3741
+
3742
+ // Fetch app info from Fenwave
3743
+ ws.send(
3744
+ JSON.stringify({
3745
+ type: "versionChangeProgress",
3746
+ requestId,
3747
+ message: "Fetching app info from Fenwave...",
3748
+ progress: 15,
3749
+ }),
3750
+ );
3751
+
3752
+ const session = loadSession();
3753
+ if (!session || !session.token) {
3754
+ throw new Error("No valid session found. Please run: fenwave login");
3755
+ }
3756
+
3757
+ // Fetch app metadata
3758
+ const backendUrl = getBackendUrl();
3759
+ const appUrl = `${backendUrl}/api/app-builder/applications/${actualFwId}`;
3760
+ const appResponse = await axios.get(appUrl, {
3761
+ headers: {
3762
+ "Content-Type": "application/json",
3763
+ Authorization: `Bearer ${session.token}`,
3764
+ },
3765
+ timeout: 10000,
3766
+ });
3767
+
3768
+ const fetchedApp = appResponse.data;
3769
+
3770
+ // Fetch all versions
3771
+ ws.send(
3772
+ JSON.stringify({
3773
+ type: "versionChangeProgress",
3774
+ requestId,
3775
+ message: "Fetching target version...",
3776
+ progress: 20,
3777
+ }),
3778
+ );
3779
+
3780
+ const versionsUrl = `${backendUrl}/api/app-builder/applications/${actualFwId}/versions`;
3781
+ const versionsResponse = await axios.get(versionsUrl, {
3782
+ headers: {
3783
+ "Content-Type": "application/json",
3784
+ Authorization: `Bearer ${session.token}`,
3785
+ },
3786
+ timeout: 10000,
3787
+ });
3788
+
3789
+ const allVersions = versionsResponse.data || [];
3790
+
3791
+ // Find the target version
3792
+ const versionData = allVersions.find((v) => v.version === targetVersion);
3793
+
3794
+ if (!versionData) {
3795
+ throw new Error(`Version ${targetVersion} not found`);
3796
+ }
3797
+
3798
+ ws.send(
3799
+ JSON.stringify({
3800
+ type: "versionChangeProgress",
3801
+ requestId,
3802
+ message: `Switching to version ${targetVersion}...`,
3803
+ progress: 25,
3804
+ }),
3805
+ );
3806
+
3807
+ // Build the app object with the target version's data
3808
+ const targetApp = {
3809
+ id: `fw-${actualFwId}`,
3810
+ fwId: actualFwId,
3811
+ name: fetchedApp.name,
3812
+ nodes: versionData.nodes || [],
3813
+ edges: versionData.edges || [],
3814
+ description: versionData.description || fetchedApp.description || "",
3815
+ version: targetVersion,
3816
+ status: versionData.status || "published",
3817
+ source: "fenwave",
3818
+ };
3819
+
3820
+ // Normalize app name for container matching
3821
+ const appName = targetApp.name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
3822
+
3823
+ // Detect existing containers
3824
+ const containers = await docker.listContainers({ all: true });
3825
+ const appContainers = containers.filter((container) => {
3826
+ const containerName = container.Names[0].replace(/^\//, "");
3827
+ const projectLabel = container.Labels["com.docker.compose.project"];
3828
+ return (
3829
+ containerName.startsWith(appName) ||
3830
+ projectLabel === appName ||
3831
+ container.Labels["app"] === appName
3832
+ );
3833
+ });
3834
+
3835
+ ws.send(
3836
+ JSON.stringify({
3837
+ type: "versionChangeProgress",
3838
+ requestId,
3839
+ message: `Stopping ${appContainers.length} containers...`,
3840
+ progress: 30,
3841
+ }),
3842
+ );
3843
+
3844
+ // Stop running containers
3845
+ for (const c of appContainers) {
3846
+ try {
3847
+ if (c.State === "running") {
3848
+ await docker.getContainer(c.Id).stop();
3849
+ }
3850
+ } catch (err) {
3851
+ console.warn("Error stopping container", c.Id, err.message);
3852
+ }
3853
+ }
3854
+
3855
+ ws.send(
3856
+ JSON.stringify({
3857
+ type: "versionChangeProgress",
3858
+ requestId,
3859
+ message: "Removing old containers...",
3860
+ progress: 50,
3861
+ }),
3862
+ );
3863
+
3864
+ // Remove containers
3865
+ for (const c of appContainers) {
3866
+ try {
3867
+ await docker.getContainer(c.Id).remove({ force: true });
3868
+ } catch (err) {
3869
+ console.warn("Error removing container", c.Id, err.message);
3870
+ }
3871
+ }
3872
+
3873
+ // Prepare YAML for the target version and run
3874
+ const yamlContent = convertNodesToYAML(targetApp);
3875
+
3876
+ ws.send(
3877
+ JSON.stringify({
3878
+ type: "versionChangeProgress",
3879
+ requestId,
3880
+ message: "Creating containers for new version...",
3881
+ progress: 70,
3882
+ }),
3883
+ );
3884
+
3885
+ // Reuse runFenwaveApp flow to create docker-compose and run
3886
+ // Pass isVersionChange context so the completion event is versionChangeCompleted
3887
+ await runFenwaveApp(ws, targetApp, yamlContent, requestId, {
3888
+ isVersionChange: true,
3889
+ targetVersion,
3890
+ });
3891
+
3892
+ console.log(
3893
+ `✅ Application "${targetApp.name} (${targetVersion})" version change initiated!`,
3894
+ );
3895
+
3896
+ // Note: versionChangeCompleted or versionChangeFailed will be sent by runFenwaveApp
3897
+ } catch (error) {
3898
+ console.error(
3899
+ chalk.red("❌ Version change failed:", error.message || error),
3900
+ );
3901
+ ws.send(
3902
+ JSON.stringify({
3903
+ type: "versionChangeFailed",
3904
+ requestId,
3905
+ error: error.message || String(error),
3906
+ }),
3907
+ );
3908
+ }
3909
+ }
3910
+
3911
+ export default { handleAppAction, checkAppHasBeenRun };
3912
+
3913
+ export { handleAppAction, checkAppHasBeenRun };