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