@aifabrix/builder 2.1.7 → 2.2.0

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/lib/infra.js CHANGED
@@ -14,11 +14,37 @@ const { promisify } = require('util');
14
14
  const path = require('path');
15
15
  const fs = require('fs');
16
16
  const os = require('os');
17
+ const handlebars = require('handlebars');
17
18
  const secrets = require('./secrets');
19
+ const config = require('./config');
20
+ const devConfig = require('./utils/dev-config');
18
21
  const logger = require('./utils/logger');
22
+ const containerUtils = require('./utils/infra-containers');
19
23
 
24
+ // Register Handlebars helper for equality check
25
+ handlebars.registerHelper('eq', (a, b) => a === b);
20
26
  const execAsync = promisify(exec);
21
27
 
28
+ /**
29
+ * Gets infrastructure directory name based on developer ID
30
+ * Dev 0: infra (no dev-0 suffix), Dev > 0: infra-dev{id}
31
+ * @param {number} devId - Developer ID
32
+ * @returns {string} Infrastructure directory name
33
+ */
34
+ function getInfraDirName(devId) {
35
+ return devId === 0 ? 'infra' : `infra-dev${devId}`;
36
+ }
37
+
38
+ /**
39
+ * Gets Docker Compose project name based on developer ID
40
+ * Dev 0: infra (no dev-0 suffix), Dev > 0: infra-dev{id}
41
+ * @param {number} devId - Developer ID
42
+ * @returns {string} Docker Compose project name
43
+ */
44
+ function getInfraProjectName(devId) {
45
+ return devId === 0 ? 'infra' : `infra-dev${devId}`;
46
+ }
47
+
22
48
  // Wrapper to support cwd option
23
49
  function execAsyncWithCwd(command, options = {}) {
24
50
  return new Promise((resolve, reject) => {
@@ -64,33 +90,53 @@ async function ensureAdminSecrets() {
64
90
  return adminSecretsPath;
65
91
  }
66
92
 
67
- async function startInfra() {
93
+ async function startInfra(developerId = null) {
68
94
  await checkDockerAvailability();
69
95
  const adminSecretsPath = await ensureAdminSecrets();
70
96
 
71
- // Load compose template
72
- const templatePath = path.join(__dirname, '..', 'templates', 'infra', 'compose.yaml');
97
+ // Get developer ID from parameter or config
98
+ const devId = developerId || await config.getDeveloperId();
99
+ const ports = devConfig.getDevPorts(devId);
100
+
101
+ // Load compose template (Handlebars)
102
+ const templatePath = path.join(__dirname, '..', 'templates', 'infra', 'compose.yaml.hbs');
73
103
  if (!fs.existsSync(templatePath)) {
74
104
  throw new Error(`Compose template not found: ${templatePath}`);
75
105
  }
76
106
 
77
- // Create infra directory in ~/.aifabrix
107
+ // Create infra directory in ~/.aifabrix with dev ID
78
108
  const aifabrixDir = path.join(os.homedir(), '.aifabrix');
79
- const infraDir = path.join(aifabrixDir, 'infra');
109
+ const infraDirName = getInfraDirName(devId);
110
+ const infraDir = path.join(aifabrixDir, infraDirName);
80
111
  if (!fs.existsSync(infraDir)) {
81
112
  fs.mkdirSync(infraDir, { recursive: true });
82
113
  }
83
114
 
115
+ // Generate compose file from template
116
+ const templateContent = fs.readFileSync(templatePath, 'utf8');
117
+ const template = handlebars.compile(templateContent);
118
+ // Dev 0: infra-aifabrix-network, Dev > 0: infra-dev{id}-aifabrix-network
119
+ const networkName = devId === 0 ? 'infra-aifabrix-network' : `infra-dev${devId}-aifabrix-network`;
120
+ const composeContent = template({
121
+ devId: devId,
122
+ postgresPort: ports.postgres,
123
+ redisPort: ports.redis,
124
+ pgadminPort: ports.pgadmin,
125
+ redisCommanderPort: ports.redisCommander,
126
+ networkName: networkName
127
+ });
128
+
84
129
  const composePath = path.join(infraDir, 'compose.yaml');
85
- fs.writeFileSync(composePath, fs.readFileSync(templatePath, 'utf8'));
130
+ fs.writeFileSync(composePath, composeContent);
86
131
 
87
132
  try {
88
133
  logger.log(`Using compose file: ${composePath}`);
89
- logger.log('Starting infrastructure services...');
90
- await execAsyncWithCwd(`docker-compose -f "${composePath}" -p infra --env-file "${adminSecretsPath}" up -d`, { cwd: infraDir });
134
+ logger.log(`Starting infrastructure services for developer ${devId}...`);
135
+ const projectName = getInfraProjectName(devId);
136
+ await execAsyncWithCwd(`docker-compose -f "${composePath}" -p ${projectName} --env-file "${adminSecretsPath}" up -d`, { cwd: infraDir });
91
137
  logger.log('Infrastructure services started successfully');
92
138
 
93
- await waitForServices();
139
+ await waitForServices(devId);
94
140
  logger.log('All services are healthy and ready');
95
141
  } finally {
96
142
  // Keep the compose file for stop commands
@@ -111,8 +157,11 @@ async function startInfra() {
111
157
  * // All infrastructure containers are stopped and removed
112
158
  */
113
159
  async function stopInfra() {
160
+ const devId = await config.getDeveloperId();
114
161
  const aifabrixDir = path.join(os.homedir(), '.aifabrix');
115
- const composePath = path.join(aifabrixDir, 'infra', 'compose.yaml');
162
+ const infraDirName = getInfraDirName(devId);
163
+ const infraDir = path.join(aifabrixDir, infraDirName);
164
+ const composePath = path.join(infraDir, 'compose.yaml');
116
165
  const adminSecretsPath = path.join(aifabrixDir, 'admin-secrets.env');
117
166
 
118
167
  if (!fs.existsSync(composePath) || !fs.existsSync(adminSecretsPath)) {
@@ -120,11 +169,10 @@ async function stopInfra() {
120
169
  return;
121
170
  }
122
171
 
123
- const infraDir = path.join(aifabrixDir, 'infra');
124
-
125
172
  try {
126
173
  logger.log('Stopping infrastructure services...');
127
- await execAsyncWithCwd(`docker-compose -f "${composePath}" -p infra --env-file "${adminSecretsPath}" down`, { cwd: infraDir });
174
+ const projectName = getInfraProjectName(devId);
175
+ await execAsyncWithCwd(`docker-compose -f "${composePath}" -p ${projectName} --env-file "${adminSecretsPath}" down`, { cwd: infraDir });
128
176
  logger.log('Infrastructure services stopped');
129
177
  } finally {
130
178
  // Keep the compose file for future use
@@ -145,8 +193,11 @@ async function stopInfra() {
145
193
  * // All infrastructure containers and data are removed
146
194
  */
147
195
  async function stopInfraWithVolumes() {
196
+ const devId = await config.getDeveloperId();
148
197
  const aifabrixDir = path.join(os.homedir(), '.aifabrix');
149
- const composePath = path.join(aifabrixDir, 'infra', 'compose.yaml');
198
+ const infraDirName = getInfraDirName(devId);
199
+ const infraDir = path.join(aifabrixDir, infraDirName);
200
+ const composePath = path.join(infraDir, 'compose.yaml');
150
201
  const adminSecretsPath = path.join(aifabrixDir, 'admin-secrets.env');
151
202
 
152
203
  if (!fs.existsSync(composePath) || !fs.existsSync(adminSecretsPath)) {
@@ -154,84 +205,16 @@ async function stopInfraWithVolumes() {
154
205
  return;
155
206
  }
156
207
 
157
- const infraDir = path.join(aifabrixDir, 'infra');
158
-
159
208
  try {
160
209
  logger.log('Stopping infrastructure services and removing all data...');
161
- await execAsyncWithCwd(`docker-compose -f "${composePath}" -p infra --env-file "${adminSecretsPath}" down -v`, { cwd: infraDir });
210
+ const projectName = getInfraProjectName(devId);
211
+ await execAsyncWithCwd(`docker-compose -f "${composePath}" -p ${projectName} --env-file "${adminSecretsPath}" down -v`, { cwd: infraDir });
162
212
  logger.log('Infrastructure services stopped and all data removed');
163
213
  } finally {
164
214
  // Keep the compose file for future use
165
215
  }
166
216
  }
167
217
 
168
- /**
169
- * Finds container by name pattern
170
- * @private
171
- * @async
172
- * @param {string} serviceName - Service name
173
- * @returns {Promise<string|null>} Container name or null if not found
174
- */
175
- async function findContainer(serviceName) {
176
- try {
177
- // Try both naming patterns: infra-* (dynamic names) and aifabrix-* (hardcoded names)
178
- let { stdout } = await execAsync(`docker ps --filter "name=infra-${serviceName}" --format "{{.Names}}"`);
179
- let containerName = stdout.trim();
180
- if (!containerName) {
181
- // Fallback to hardcoded names
182
- ({ stdout } = await execAsync(`docker ps --filter "name=aifabrix-${serviceName}" --format "{{.Names}}"`));
183
- containerName = stdout.trim();
184
- }
185
- return containerName;
186
- } catch (error) {
187
- return null;
188
- }
189
- }
190
-
191
- /**
192
- * Checks health status for a service with health checks
193
- * @private
194
- * @async
195
- * @param {string} serviceName - Service name
196
- * @returns {Promise<string>} Health status
197
- */
198
- async function checkServiceWithHealthCheck(serviceName) {
199
- try {
200
- const containerName = await findContainer(serviceName);
201
- if (!containerName) {
202
- return 'unknown';
203
- }
204
- const { stdout } = await execAsync(`docker inspect --format='{{.State.Health.Status}}' ${containerName}`);
205
- const status = stdout.trim().replace(/['"]/g, '');
206
- // Accept both 'healthy' and 'starting' as healthy (starting means it's initializing)
207
- return (status === 'healthy' || status === 'starting') ? 'healthy' : status;
208
- } catch (error) {
209
- return 'unknown';
210
- }
211
- }
212
-
213
- /**
214
- * Checks health status for a service without health checks
215
- * @private
216
- * @async
217
- * @param {string} serviceName - Service name
218
- * @returns {Promise<string>} Health status
219
- */
220
- async function checkServiceWithoutHealthCheck(serviceName) {
221
- try {
222
- const containerName = await findContainer(serviceName);
223
- if (!containerName) {
224
- return 'unknown';
225
- }
226
- const { stdout } = await execAsync(`docker inspect --format='{{.State.Status}}' ${containerName}`);
227
- const status = stdout.trim().replace(/['"]/g, '');
228
- // Treat 'running' or 'healthy' as 'healthy' for services without health checks
229
- return (status === 'running' || status === 'healthy') ? 'healthy' : 'unhealthy';
230
- } catch (error) {
231
- return 'unknown';
232
- }
233
- }
234
-
235
218
  /**
236
219
  * Checks if infrastructure services are running
237
220
  * Validates that all required services are healthy and accessible
@@ -245,19 +228,20 @@ async function checkServiceWithoutHealthCheck(serviceName) {
245
228
  * const health = await checkInfraHealth();
246
229
  * // Returns: { postgres: 'healthy', redis: 'healthy', keycloak: 'healthy', controller: 'healthy' }
247
230
  */
248
- async function checkInfraHealth() {
231
+ async function checkInfraHealth(devId = null) {
232
+ const developerId = devId || await config.getDeveloperId();
249
233
  const servicesWithHealthCheck = ['postgres', 'redis'];
250
234
  const servicesWithoutHealthCheck = ['pgadmin', 'redis-commander'];
251
235
  const health = {};
252
236
 
253
237
  // Check health status for services with health checks
254
238
  for (const service of servicesWithHealthCheck) {
255
- health[service] = await checkServiceWithHealthCheck(service);
239
+ health[service] = await containerUtils.checkServiceWithHealthCheck(service, developerId);
256
240
  }
257
241
 
258
242
  // Check if services without health checks are running
259
243
  for (const service of servicesWithoutHealthCheck) {
260
- health[service] = await checkServiceWithoutHealthCheck(service);
244
+ health[service] = await containerUtils.checkServiceWithoutHealthCheck(service, developerId);
261
245
  }
262
246
 
263
247
  return health;
@@ -276,39 +260,41 @@ async function checkInfraHealth() {
276
260
  * // Returns: { postgres: { status: 'running', port: 5432, url: 'localhost:5432' }, ... }
277
261
  */
278
262
  async function getInfraStatus() {
263
+ const devId = await config.getDeveloperId();
264
+ const ports = devConfig.getDevPorts(devId);
279
265
  const services = {
280
- postgres: { port: 5432, url: 'localhost:5432' },
281
- redis: { port: 6379, url: 'localhost:6379' },
282
- pgadmin: { port: 5050, url: 'http://localhost:5050' },
283
- 'redis-commander': { port: 8081, url: 'http://localhost:8081' }
266
+ postgres: { port: ports.postgres, url: `localhost:${ports.postgres}` },
267
+ redis: { port: ports.redis, url: `localhost:${ports.redis}` },
268
+ pgadmin: { port: ports.pgadmin, url: `http://localhost:${ports.pgadmin}` },
269
+ 'redis-commander': { port: ports.redisCommander, url: `http://localhost:${ports.redisCommander}` }
284
270
  };
285
271
 
286
272
  const status = {};
287
273
 
288
- for (const [serviceName, config] of Object.entries(services)) {
274
+ for (const [serviceName, serviceConfig] of Object.entries(services)) {
289
275
  try {
290
- const containerName = await findContainer(serviceName);
276
+ const containerName = await containerUtils.findContainer(serviceName, devId);
291
277
  if (containerName) {
292
278
  const { stdout } = await execAsync(`docker inspect --format='{{.State.Status}}' ${containerName}`);
293
279
  // Normalize status value (trim whitespace and remove quotes)
294
280
  const normalizedStatus = stdout.trim().replace(/['"]/g, '');
295
281
  status[serviceName] = {
296
282
  status: normalizedStatus,
297
- port: config.port,
298
- url: config.url
283
+ port: serviceConfig.port,
284
+ url: serviceConfig.url
299
285
  };
300
286
  } else {
301
287
  status[serviceName] = {
302
288
  status: 'not running',
303
- port: config.port,
304
- url: config.url
289
+ port: serviceConfig.port,
290
+ url: serviceConfig.url
305
291
  };
306
292
  }
307
293
  } catch (error) {
308
294
  status[serviceName] = {
309
295
  status: 'not running',
310
- port: config.port,
311
- url: config.url
296
+ port: serviceConfig.port,
297
+ url: serviceConfig.url
312
298
  };
313
299
  }
314
300
  }
@@ -340,19 +326,21 @@ async function restartService(serviceName) {
340
326
  throw new Error(`Invalid service name. Must be one of: ${validServices.join(', ')}`);
341
327
  }
342
328
 
329
+ const devId = await config.getDeveloperId();
343
330
  const aifabrixDir = path.join(os.homedir(), '.aifabrix');
344
- const composePath = path.join(aifabrixDir, 'infra', 'compose.yaml');
331
+ const infraDirName = getInfraDirName(devId);
332
+ const infraDir = path.join(aifabrixDir, infraDirName);
333
+ const composePath = path.join(infraDir, 'compose.yaml');
345
334
  const adminSecretsPath = path.join(aifabrixDir, 'admin-secrets.env');
346
335
 
347
336
  if (!fs.existsSync(composePath) || !fs.existsSync(adminSecretsPath)) {
348
337
  throw new Error('Infrastructure not properly configured');
349
338
  }
350
339
 
351
- const infraDir = path.join(aifabrixDir, 'infra');
352
-
353
340
  try {
354
341
  logger.log(`Restarting ${serviceName} service...`);
355
- await execAsyncWithCwd(`docker-compose -f "${composePath}" -p infra --env-file "${adminSecretsPath}" restart ${serviceName}`, { cwd: infraDir });
342
+ const projectName = getInfraProjectName(devId);
343
+ await execAsyncWithCwd(`docker-compose -f "${composePath}" -p ${projectName} --env-file "${adminSecretsPath}" restart ${serviceName}`, { cwd: infraDir });
356
344
  logger.log(`${serviceName} service restarted successfully`);
357
345
  } finally {
358
346
  // Keep the compose file for future use
@@ -362,13 +350,14 @@ async function restartService(serviceName) {
362
350
  /**
363
351
  * Waits for services to be healthy
364
352
  * @private
353
+ * @param {number} [devId] - Developer ID (optional, will be loaded from config if not provided)
365
354
  */
366
- async function waitForServices() {
355
+ async function waitForServices(devId = null) {
367
356
  const maxAttempts = 30;
368
357
  const delay = 2000; // 2 seconds
369
358
 
370
359
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
371
- const health = await checkInfraHealth();
360
+ const health = await checkInfraHealth(devId);
372
361
  const allHealthy = Object.values(health).every(status => status === 'healthy');
373
362
 
374
363
  if (allHealthy) {
@@ -386,12 +375,88 @@ async function waitForServices() {
386
375
  throw new Error('Services failed to become healthy within timeout period');
387
376
  }
388
377
 
378
+ /**
379
+ * Gets status of running application containers
380
+ * Finds all containers matching pattern aifabrix-dev{id}-* (excluding infrastructure)
381
+ *
382
+ * @async
383
+ * @function getAppStatus
384
+ * @returns {Promise<Array>} Array of application status objects
385
+ *
386
+ * @example
387
+ * const apps = await getAppStatus();
388
+ * // Returns: [{ name: 'myapp', container: 'aifabrix-dev1-myapp', port: '3100:3000', status: 'running', url: 'http://localhost:3100' }]
389
+ */
390
+ async function getAppStatus() {
391
+ const devId = await config.getDeveloperId();
392
+ const apps = [];
393
+
394
+ try {
395
+ // Find all containers with pattern
396
+ // Dev 0: aifabrix-* (but exclude infrastructure containers)
397
+ // Dev > 0: aifabrix-dev{id}-*
398
+ const filterPattern = devId === 0 ? 'aifabrix-' : `aifabrix-dev${devId}-`;
399
+ const { stdout } = await execAsync(`docker ps --filter "name=${filterPattern}" --format "{{.Names}}\t{{.Ports}}\t{{.Status}}"`);
400
+ const lines = stdout.trim().split('\n').filter(line => line.trim() !== '');
401
+
402
+ // Infrastructure container names to exclude
403
+ // Dev 0: aifabrix-{serviceName}, Dev > 0: aifabrix-dev{id}-{serviceName}
404
+ const infraContainers = devId === 0
405
+ ? [
406
+ 'aifabrix-postgres',
407
+ 'aifabrix-redis',
408
+ 'aifabrix-pgadmin',
409
+ 'aifabrix-redis-commander'
410
+ ]
411
+ : [
412
+ `aifabrix-dev${devId}-postgres`,
413
+ `aifabrix-dev${devId}-redis`,
414
+ `aifabrix-dev${devId}-pgadmin`,
415
+ `aifabrix-dev${devId}-redis-commander`
416
+ ];
417
+
418
+ for (const line of lines) {
419
+ const [containerName, ports, status] = line.split('\t');
420
+
421
+ // Skip infrastructure containers
422
+ if (infraContainers.includes(containerName)) {
423
+ continue;
424
+ }
425
+
426
+ // Extract app name from container name
427
+ // Dev 0: aifabrix-{appName}, Dev > 0: aifabrix-dev{id}-{appName}
428
+ const pattern = devId === 0
429
+ ? /^aifabrix-(.+)$/
430
+ : new RegExp(`^aifabrix-dev${devId}-(.+)$`);
431
+ const appNameMatch = containerName.match(pattern);
432
+ if (!appNameMatch) {
433
+ continue;
434
+ }
435
+
436
+ const appName = appNameMatch[1];
437
+
438
+ // Extract host port from ports string (e.g., "0.0.0.0:3100->3000/tcp")
439
+ const portMatch = ports.match(/:(\d+)->\d+\//);
440
+ const hostPort = portMatch ? portMatch[1] : 'unknown';
441
+ const url = hostPort !== 'unknown' ? `http://localhost:${hostPort}` : 'unknown';
442
+
443
+ apps.push({
444
+ name: appName,
445
+ container: containerName,
446
+ port: ports,
447
+ status: status.trim(),
448
+ url: url
449
+ });
450
+ }
451
+ } catch (error) {
452
+ // If no containers found, return empty array
453
+ return [];
454
+ }
455
+
456
+ return apps;
457
+ }
458
+
389
459
  module.exports = {
390
- startInfra,
391
- stopInfra,
392
- stopInfraWithVolumes,
393
- checkInfraHealth,
394
- getInfraStatus,
395
- restartService,
396
- ensureAdminSecrets
460
+ startInfra, stopInfra, stopInfraWithVolumes, checkInfraHealth,
461
+ getInfraStatus, getAppStatus, restartService, ensureAdminSecrets
397
462
  };
package/lib/secrets.js CHANGED
@@ -15,6 +15,8 @@ const yaml = require('js-yaml');
15
15
  const os = require('os');
16
16
  const chalk = require('chalk');
17
17
  const logger = require('./utils/logger');
18
+ const config = require('./config');
19
+ const devConfig = require('./utils/dev-config');
18
20
  const {
19
21
  generateMissingSecrets,
20
22
  createDefaultSecrets
@@ -303,6 +305,21 @@ async function generateEnvFile(appName, secretsPath, environment = 'local', forc
303
305
  resolved = resolveServicePortsInEnvContent(resolved, environment);
304
306
  }
305
307
 
308
+ // For local environment, update infrastructure ports to use dev-specific ports
309
+ if (environment === 'local') {
310
+ const devId = await config.getDeveloperId();
311
+ const ports = devConfig.getDevPorts(devId);
312
+
313
+ // Update DATABASE_PORT if present
314
+ resolved = resolved.replace(/^DATABASE_PORT\s*=\s*.*$/m, `DATABASE_PORT=${ports.postgres}`);
315
+
316
+ // Update REDIS_URL if present (format: redis://localhost:port)
317
+ resolved = resolved.replace(/^REDIS_URL\s*=\s*redis:\/\/localhost:\d+/m, `REDIS_URL=redis://localhost:${ports.redis}`);
318
+
319
+ // Update REDIS_HOST if it contains a port
320
+ resolved = resolved.replace(/^REDIS_HOST\s*=\s*localhost:\d+/m, `REDIS_HOST=localhost:${ports.redis}`);
321
+ }
322
+
306
323
  fs.writeFileSync(envPath, resolved, { mode: 0o600 });
307
324
 
308
325
  // Update PORT variable in container .env file to use port (from variables.yaml)