@aifabrix/builder 2.3.0 → 2.3.3

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