@aifabrix/builder 2.3.0 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,381 @@
1
+ /**
2
+ * AI Fabrix Builder - App Run Helpers
3
+ *
4
+ * Extracted helper functions to keep lib/app-run.js small and maintainable.
5
+ * These helpers encapsulate image checks, validation, env prep, and container start logic.
6
+ *
7
+ * @fileoverview Helper functions for application run workflow
8
+ * @author AI Fabrix Team
9
+ * @version 2.0.0
10
+ */
11
+
12
+ const fs = require('fs').promises;
13
+ const fsSync = require('fs');
14
+ const path = require('path');
15
+ const chalk = require('chalk');
16
+ const yaml = require('js-yaml');
17
+ const { exec } = require('child_process');
18
+ const { promisify } = require('util');
19
+ const validator = require('./validator');
20
+ const infra = require('./infra');
21
+ const secrets = require('./secrets');
22
+ const config = require('./config');
23
+ const buildCopy = require('./utils/build-copy');
24
+ const logger = require('./utils/logger');
25
+ const { waitForHealthCheck } = require('./utils/health-check');
26
+ const composeGenerator = require('./utils/compose-generator');
27
+
28
+ const execAsync = promisify(exec);
29
+
30
+ /**
31
+ * Checks if Docker image exists for the application
32
+ * @param {string} imageName - Image name (can include repository prefix)
33
+ * @param {string} tag - Image tag (default: latest)
34
+ * @param {boolean} [debug=false] - Enable debug logging
35
+ * @returns {Promise<boolean>} True if image exists
36
+ */
37
+ async function checkImageExists(imageName, tag = 'latest', debug = false) {
38
+ try {
39
+ const fullImageName = `${imageName}:${tag}`;
40
+ const cmd = `docker images --format "{{.Repository}}:{{.Tag}}" --filter "reference=${fullImageName}"`;
41
+ if (debug) {
42
+ logger.log(chalk.gray(`[DEBUG] Executing: ${cmd}`));
43
+ }
44
+ const { stdout } = await execAsync(cmd);
45
+ const lines = stdout.trim().split('\n').filter(line => line.trim() !== '');
46
+ const exists = lines.some(line => line.trim() === fullImageName);
47
+ if (debug) {
48
+ logger.log(chalk.gray(`[DEBUG] Image ${fullImageName} exists: ${exists}`));
49
+ }
50
+ return exists;
51
+ } catch (error) {
52
+ if (debug) {
53
+ logger.log(chalk.gray(`[DEBUG] Image check failed: ${error.message}`));
54
+ }
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Checks if container is already running
61
+ * @param {string} appName - Application name
62
+ * @param {number|string} developerId - Developer ID (0 = default infra, > 0 = developer-specific; string allowed)
63
+ * @param {boolean} [debug=false] - Enable debug logging
64
+ * @returns {Promise<boolean>} True if container is running
65
+ */
66
+ async function checkContainerRunning(appName, developerId, debug = false) {
67
+ try {
68
+ const idNum = typeof developerId === 'string' ? parseInt(developerId, 10) : developerId;
69
+ const containerName = idNum === 0 ? `aifabrix-${appName}` : `aifabrix-dev${developerId}-${appName}`;
70
+ const cmd = `docker ps --filter "name=${containerName}" --format "{{.Names}}"`;
71
+ if (debug) {
72
+ logger.log(chalk.gray(`[DEBUG] Executing: ${cmd}`));
73
+ }
74
+ const { stdout } = await execAsync(cmd);
75
+ const isRunning = stdout.trim() === containerName;
76
+ if (debug) {
77
+ logger.log(chalk.gray(`[DEBUG] Container ${containerName} running: ${isRunning}`));
78
+ if (isRunning) {
79
+ const statusCmd = `docker ps --filter "name=${containerName}" --format "{{.Status}}"`;
80
+ const { stdout: status } = await execAsync(statusCmd);
81
+ const portsCmd = `docker ps --filter "name=${containerName}" --format "{{.Ports}}"`;
82
+ const { stdout: ports } = await execAsync(portsCmd);
83
+ logger.log(chalk.gray(`[DEBUG] Container status: ${status.trim()}`));
84
+ logger.log(chalk.gray(`[DEBUG] Container ports: ${ports.trim()}`));
85
+ }
86
+ }
87
+ return isRunning;
88
+ } catch (error) {
89
+ if (debug) {
90
+ logger.log(chalk.gray(`[DEBUG] Container check failed: ${error.message}`));
91
+ }
92
+ return false;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Stops and removes existing container
98
+ * @param {string} appName - Application name
99
+ * @param {number|string} developerId - Developer ID (0 = default infra, > 0 = developer-specific; string allowed)
100
+ * @param {boolean} [debug=false] - Enable debug logging
101
+ */
102
+ async function stopAndRemoveContainer(appName, developerId, debug = false) {
103
+ try {
104
+ const idNum = typeof developerId === 'string' ? parseInt(developerId, 10) : developerId;
105
+ const containerName = idNum === 0 ? `aifabrix-${appName}` : `aifabrix-dev${developerId}-${appName}`;
106
+ logger.log(chalk.yellow(`Stopping existing container ${containerName}...`));
107
+ const stopCmd = `docker stop ${containerName}`;
108
+ if (debug) {
109
+ logger.log(chalk.gray(`[DEBUG] Executing: ${stopCmd}`));
110
+ }
111
+ await execAsync(stopCmd);
112
+ const rmCmd = `docker rm ${containerName}`;
113
+ if (debug) {
114
+ logger.log(chalk.gray(`[DEBUG] Executing: ${rmCmd}`));
115
+ }
116
+ await execAsync(rmCmd);
117
+ logger.log(chalk.green(`✓ Container ${containerName} stopped and removed`));
118
+ } catch (error) {
119
+ if (debug) {
120
+ logger.log(chalk.gray(`[DEBUG] Stop/remove container error: ${error.message}`));
121
+ }
122
+ const idNum2 = typeof developerId === 'string' ? parseInt(developerId, 10) : developerId;
123
+ const containerName = idNum2 === 0 ? `aifabrix-${appName}` : `aifabrix-dev${developerId}-${appName}`;
124
+ logger.log(chalk.gray(`Container ${containerName} was not running`));
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Validates app name and loads configuration
130
+ * @async
131
+ * @param {string} appName - Application name
132
+ * @returns {Promise<Object>} Application configuration
133
+ * @throws {Error} If validation fails
134
+ */
135
+ async function validateAppConfiguration(appName) {
136
+ if (!appName || typeof appName !== 'string') {
137
+ throw new Error('Application name is required');
138
+ }
139
+
140
+ const currentDir = process.cwd();
141
+ const normalizedPath = currentDir.replace(/\\/g, '/');
142
+ const expectedBuilderPath = `builder/${appName}`;
143
+
144
+ if (normalizedPath.endsWith(expectedBuilderPath)) {
145
+ const projectRoot = path.resolve(currentDir, '../..');
146
+ throw new Error(
147
+ 'You\'re running from inside the builder directory.\n' +
148
+ `Current directory: ${currentDir}\n` +
149
+ 'Please change to the project root and try again:\n' +
150
+ ` cd ${projectRoot}\n` +
151
+ ` aifabrix run ${appName}`
152
+ );
153
+ }
154
+
155
+ const configPath = path.join(process.cwd(), 'builder', appName, 'variables.yaml');
156
+ if (!fsSync.existsSync(configPath)) {
157
+ const expectedDir = path.join(currentDir, 'builder', appName);
158
+ throw new Error(
159
+ `Application configuration not found: ${configPath}\n` +
160
+ `Current directory: ${currentDir}\n` +
161
+ `Expected location: ${expectedDir}\n` +
162
+ 'Make sure you\'re running from the project root (where \'builder\' directory exists)\n' +
163
+ `Run 'aifabrix create ${appName}' first if configuration doesn't exist`
164
+ );
165
+ }
166
+
167
+ const configContent = fsSync.readFileSync(configPath, 'utf8');
168
+ const appConfig = yaml.load(configContent);
169
+
170
+ const validation = await validator.validateApplication(appName);
171
+ if (!validation.valid) {
172
+ const allErrors = [];
173
+
174
+ if (validation.variables && validation.variables.errors && validation.variables.errors.length > 0) {
175
+ allErrors.push('variables.yaml:');
176
+ allErrors.push(...validation.variables.errors.map(err => ` ${err}`));
177
+ }
178
+ if (validation.rbac && validation.rbac.errors && validation.rbac.errors.length > 0) {
179
+ allErrors.push('rbac.yaml:');
180
+ allErrors.push(...validation.rbac.errors.map(err => ` ${err}`));
181
+ }
182
+ if (validation.env && validation.env.errors && validation.env.errors.length > 0) {
183
+ allErrors.push('env.template:');
184
+ allErrors.push(...validation.env.errors.map(err => ` ${err}`));
185
+ }
186
+
187
+ if (allErrors.length === 0) {
188
+ throw new Error('Configuration validation failed');
189
+ }
190
+
191
+ throw new Error(`Configuration validation failed:\n${allErrors.join('\n')}`);
192
+ }
193
+
194
+ return appConfig;
195
+ }
196
+
197
+ /**
198
+ * Checks prerequisites: Docker image and infrastructure
199
+ * @async
200
+ * @param {string} appName - Application name
201
+ * @param {Object} appConfig - Application configuration
202
+ * @param {boolean} [debug=false] - Enable debug logging
203
+ * @throws {Error} If prerequisites are not met
204
+ */
205
+ async function checkPrerequisites(appName, appConfig, debug = false) {
206
+ const imageName = composeGenerator.getImageName(appConfig, appName);
207
+ const imageTag = appConfig.image?.tag || 'latest';
208
+ const fullImageName = `${imageName}:${imageTag}`;
209
+
210
+ if (debug) {
211
+ logger.log(chalk.gray(`[DEBUG] Image name: ${imageName}, tag: ${imageTag}`));
212
+ }
213
+
214
+ logger.log(chalk.blue(`Checking if image ${fullImageName} exists...`));
215
+ const imageExists = await checkImageExists(imageName, imageTag, debug);
216
+ if (!imageExists) {
217
+ throw new Error(`Docker image ${fullImageName} not found\nRun 'aifabrix build ${appName}' first`);
218
+ }
219
+ logger.log(chalk.green(`✓ Image ${fullImageName} found`));
220
+
221
+ logger.log(chalk.blue('Checking infrastructure health...'));
222
+ const infraHealth = await infra.checkInfraHealth();
223
+ if (debug) {
224
+ logger.log(chalk.gray(`[DEBUG] Infrastructure health: ${JSON.stringify(infraHealth, null, 2)}`));
225
+ }
226
+ const unhealthyServices = Object.entries(infraHealth)
227
+ .filter(([_, status]) => status !== 'healthy')
228
+ .map(([service, _]) => service);
229
+
230
+ if (unhealthyServices.length > 0) {
231
+ throw new Error(`Infrastructure services not healthy: ${unhealthyServices.join(', ')}\nRun 'aifabrix up' first`);
232
+ }
233
+ logger.log(chalk.green('✓ Infrastructure is running'));
234
+ }
235
+
236
+ /**
237
+ * Prepares environment: ensures .env file and generates Docker Compose
238
+ * @async
239
+ * @param {string} appName - Application name
240
+ * @param {Object} appConfig - Application configuration
241
+ * @param {Object} options - Run options
242
+ * @returns {Promise<string>} Path to generated compose file
243
+ */
244
+ async function prepareEnvironment(appName, appConfig, options) {
245
+ const developerId = await config.getDeveloperId();
246
+ const devDir = buildCopy.getDevDirectory(appName, developerId);
247
+
248
+ if (!fsSync.existsSync(devDir)) {
249
+ await buildCopy.copyBuilderToDevDirectory(appName, developerId);
250
+ }
251
+
252
+ const builderEnvPath = path.join(process.cwd(), 'builder', appName, '.env');
253
+ if (!fsSync.existsSync(builderEnvPath)) {
254
+ logger.log(chalk.yellow('Generating .env file from template...'));
255
+ await secrets.generateEnvFile(appName, null, 'docker');
256
+ } else {
257
+ logger.log(chalk.blue('Updating .env file for Docker environment...'));
258
+ await secrets.generateEnvFile(appName, null, 'docker');
259
+ }
260
+
261
+ const devEnvPath = path.join(devDir, '.env');
262
+ if (fsSync.existsSync(builderEnvPath)) {
263
+ await fs.copyFile(builderEnvPath, devEnvPath);
264
+ }
265
+
266
+ const variablesPath = path.join(devDir, 'variables.yaml');
267
+ if (fsSync.existsSync(variablesPath)) {
268
+ const variablesContent = fsSync.readFileSync(variablesPath, 'utf8');
269
+ const variables = yaml.load(variablesContent);
270
+
271
+ if (variables?.build?.envOutputPath && variables.build.envOutputPath !== null) {
272
+ logger.log(chalk.blue('Ensuring .env file in apps/ directory is updated for Docker...'));
273
+ await secrets.generateEnvFile(appName, null, 'docker');
274
+ if (fsSync.existsSync(builderEnvPath)) {
275
+ await fs.copyFile(builderEnvPath, devEnvPath);
276
+ }
277
+ }
278
+ }
279
+
280
+ logger.log(chalk.blue('Generating Docker Compose configuration...'));
281
+ const composeOptions = { ...options };
282
+ if (!composeOptions.port) {
283
+ const basePort = appConfig.port || 3000;
284
+ const idNum = typeof developerId === 'string' ? parseInt(developerId, 10) : developerId;
285
+ composeOptions.port = idNum === 0 ? basePort : basePort + (idNum * 100);
286
+ }
287
+ const composeContent = await composeGenerator.generateDockerCompose(appName, appConfig, composeOptions);
288
+
289
+ if (!fsSync.existsSync(devDir)) {
290
+ await buildCopy.copyBuilderToDevDirectory(appName, developerId);
291
+ }
292
+ const tempComposePath = path.join(devDir, 'docker-compose.yaml');
293
+ await fs.writeFile(tempComposePath, composeContent);
294
+
295
+ return tempComposePath;
296
+ }
297
+
298
+ /**
299
+ * Starts the container and waits for health check
300
+ * @async
301
+ * @param {string} appName - Application name
302
+ * @param {string} composePath - Path to Docker Compose file
303
+ * @param {number} port - Application port
304
+ * @param {Object} appConfig - Application configuration
305
+ * @param {boolean} [debug=false] - Enable debug logging
306
+ * @throws {Error} If container fails to start or become healthy
307
+ */
308
+ async function startContainer(appName, composePath, port, appConfig = null, debug = false) {
309
+ logger.log(chalk.blue(`Starting ${appName}...`));
310
+
311
+ const adminSecretsPath = await infra.ensureAdminSecrets();
312
+ if (debug) {
313
+ logger.log(chalk.gray(`[DEBUG] Admin secrets path: ${adminSecretsPath}`));
314
+ }
315
+
316
+ const adminSecretsContent = fsSync.readFileSync(adminSecretsPath, 'utf8');
317
+ const postgresPasswordMatch = adminSecretsContent.match(/^POSTGRES_PASSWORD=(.+)$/m);
318
+ const postgresPassword = postgresPasswordMatch ? postgresPasswordMatch[1] : '';
319
+
320
+ const env = {
321
+ ...process.env,
322
+ ADMIN_SECRETS_PATH: adminSecretsPath,
323
+ POSTGRES_PASSWORD: postgresPassword
324
+ };
325
+
326
+ if (debug) {
327
+ logger.log(chalk.gray(`[DEBUG] Environment variables: ADMIN_SECRETS_PATH=${adminSecretsPath}, POSTGRES_PASSWORD=${postgresPassword ? '***' : '(not set)'}`));
328
+ }
329
+
330
+ const composeCmd = `docker-compose -f "${composePath}" up -d`;
331
+ if (debug) {
332
+ logger.log(chalk.gray(`[DEBUG] Executing: ${composeCmd}`));
333
+ logger.log(chalk.gray(`[DEBUG] Compose file: ${composePath}`));
334
+ }
335
+ await execAsync(composeCmd, { env });
336
+
337
+ const idNum = typeof appConfig.developerId === 'string' ? parseInt(appConfig.developerId, 10) : appConfig.developerId;
338
+ const containerName = idNum === 0 ? `aifabrix-${appName}` : `aifabrix-dev${appConfig.developerId}-${appName}`;
339
+ logger.log(chalk.green(`✓ Container ${containerName} started`));
340
+ if (debug) {
341
+ const statusCmd = `docker ps --filter "name=${containerName}" --format "{{.Status}}"`;
342
+ const { stdout: status } = await execAsync(statusCmd);
343
+ const portsCmd = `docker ps --filter "name=${containerName}" --format "{{.Ports}}"`;
344
+ const { stdout: ports } = await execAsync(portsCmd);
345
+ logger.log(chalk.gray(`[DEBUG] Container status: ${status.trim()}`));
346
+ logger.log(chalk.gray(`[DEBUG] Container ports: ${ports.trim()}`));
347
+ }
348
+
349
+ const healthCheckPath = appConfig?.healthCheck?.path || '/health';
350
+ logger.log(chalk.blue(`Waiting for application to be healthy at http://localhost:${port}${healthCheckPath}...`));
351
+ await waitForHealthCheck(appName, 90, port, appConfig, debug);
352
+ }
353
+
354
+ /**
355
+ * Displays run status after successful start
356
+ * @param {string} appName - Application name
357
+ * @param {number} port - Application port
358
+ * @param {Object} appConfig - Application configuration (with developerId property)
359
+ */
360
+ async function displayRunStatus(appName, port, appConfig) {
361
+ const idNum = typeof appConfig.developerId === 'string' ? parseInt(appConfig.developerId, 10) : appConfig.developerId;
362
+ const containerName = idNum === 0 ? `aifabrix-${appName}` : `aifabrix-dev${appConfig.developerId}-${appName}`;
363
+ const healthCheckPath = appConfig?.healthCheck?.path || '/health';
364
+ const healthCheckUrl = `http://localhost:${port}${healthCheckPath}`;
365
+
366
+ logger.log(chalk.green(`\n✓ App running at http://localhost:${port}`));
367
+ logger.log(chalk.blue(`Health check: ${healthCheckUrl}`));
368
+ logger.log(chalk.gray(`Container: ${containerName}`));
369
+ }
370
+
371
+ module.exports = {
372
+ checkImageExists,
373
+ checkContainerRunning,
374
+ stopAndRemoveContainer,
375
+ validateAppConfiguration,
376
+ checkPrerequisites,
377
+ prepareEnvironment,
378
+ startContainer,
379
+ displayRunStatus
380
+ };
381
+