@oussema_mili/test-pkg-123 1.1.22

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.

Potentially problematic release.


This version of @oussema_mili/test-pkg-123 might be problematic. Click here for more details.

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