@aifabrix/builder 2.0.0 → 2.0.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.
Files changed (61) hide show
  1. package/README.md +5 -3
  2. package/bin/aifabrix.js +9 -3
  3. package/jest.config.integration.js +30 -0
  4. package/lib/app-config.js +157 -0
  5. package/lib/app-deploy.js +233 -82
  6. package/lib/app-dockerfile.js +112 -0
  7. package/lib/app-prompts.js +244 -0
  8. package/lib/app-push.js +172 -0
  9. package/lib/app-run.js +235 -144
  10. package/lib/app.js +208 -274
  11. package/lib/audit-logger.js +2 -0
  12. package/lib/build.js +177 -125
  13. package/lib/cli.js +76 -86
  14. package/lib/commands/app.js +414 -0
  15. package/lib/commands/login.js +304 -0
  16. package/lib/config.js +78 -0
  17. package/lib/deployer.js +225 -81
  18. package/lib/env-reader.js +45 -30
  19. package/lib/generator.js +308 -191
  20. package/lib/github-generator.js +67 -7
  21. package/lib/infra.js +156 -61
  22. package/lib/push.js +105 -10
  23. package/lib/schema/application-schema.json +30 -2
  24. package/lib/schema/env-config.yaml +9 -1
  25. package/lib/schema/infrastructure-schema.json +589 -0
  26. package/lib/secrets.js +229 -24
  27. package/lib/template-validator.js +205 -0
  28. package/lib/templates.js +305 -170
  29. package/lib/utils/api.js +329 -0
  30. package/lib/utils/cli-utils.js +97 -0
  31. package/lib/utils/compose-generator.js +185 -0
  32. package/lib/utils/docker-build.js +173 -0
  33. package/lib/utils/dockerfile-utils.js +131 -0
  34. package/lib/utils/environment-checker.js +125 -0
  35. package/lib/utils/error-formatter.js +61 -0
  36. package/lib/utils/health-check.js +187 -0
  37. package/lib/utils/logger.js +53 -0
  38. package/lib/utils/template-helpers.js +223 -0
  39. package/lib/utils/variable-transformer.js +271 -0
  40. package/lib/validator.js +27 -112
  41. package/package.json +14 -10
  42. package/templates/README.md +75 -3
  43. package/templates/applications/keycloak/Dockerfile +36 -0
  44. package/templates/applications/keycloak/env.template +32 -0
  45. package/templates/applications/keycloak/rbac.yaml +37 -0
  46. package/templates/applications/keycloak/variables.yaml +56 -0
  47. package/templates/applications/miso-controller/Dockerfile +125 -0
  48. package/templates/applications/miso-controller/env.template +129 -0
  49. package/templates/applications/miso-controller/rbac.yaml +214 -0
  50. package/templates/applications/miso-controller/variables.yaml +56 -0
  51. package/templates/github/release.yaml.hbs +5 -26
  52. package/templates/github/steps/npm.hbs +24 -0
  53. package/templates/infra/compose.yaml +6 -6
  54. package/templates/python/docker-compose.hbs +19 -12
  55. package/templates/python/main.py +80 -0
  56. package/templates/python/requirements.txt +4 -0
  57. package/templates/typescript/Dockerfile.hbs +2 -2
  58. package/templates/typescript/docker-compose.hbs +19 -12
  59. package/templates/typescript/index.ts +116 -0
  60. package/templates/typescript/package.json +26 -0
  61. package/templates/typescript/tsconfig.json +24 -0
package/lib/app-run.js CHANGED
@@ -15,24 +15,30 @@ const path = require('path');
15
15
  const net = require('net');
16
16
  const chalk = require('chalk');
17
17
  const yaml = require('js-yaml');
18
- const handlebars = require('handlebars');
19
18
  const { exec } = require('child_process');
20
19
  const { promisify } = require('util');
21
20
  const validator = require('./validator');
22
21
  const infra = require('./infra');
23
22
  const secrets = require('./secrets');
23
+ const logger = require('./utils/logger');
24
+ const { waitForHealthCheck } = require('./utils/health-check');
25
+ const composeGenerator = require('./utils/compose-generator');
24
26
 
25
27
  const execAsync = promisify(exec);
26
28
 
27
29
  /**
28
30
  * Checks if Docker image exists for the application
29
- * @param {string} appName - Application name
31
+ * @param {string} imageName - Image name (can include repository prefix)
32
+ * @param {string} tag - Image tag (default: latest)
30
33
  * @returns {Promise<boolean>} True if image exists
31
34
  */
32
- async function checkImageExists(appName) {
35
+ async function checkImageExists(imageName, tag = 'latest') {
33
36
  try {
34
- const { stdout } = await execAsync(`docker images --format "{{.Repository}}:{{.Tag}}" | grep "^${appName}:latest$"`);
35
- return stdout.trim() === `${appName}:latest`;
37
+ const fullImageName = `${imageName}:${tag}`;
38
+ // Use Docker's native filtering for cross-platform compatibility (Windows-safe)
39
+ const { stdout } = await execAsync(`docker images --format "{{.Repository}}:{{.Tag}}" --filter "reference=${fullImageName}"`);
40
+ const lines = stdout.trim().split('\n').filter(line => line.trim() !== '');
41
+ return lines.some(line => line.trim() === fullImageName);
36
42
  } catch (error) {
37
43
  return false;
38
44
  }
@@ -58,13 +64,13 @@ async function checkContainerRunning(appName) {
58
64
  */
59
65
  async function stopAndRemoveContainer(appName) {
60
66
  try {
61
- console.log(chalk.yellow(`Stopping existing container aifabrix-${appName}...`));
67
+ logger.log(chalk.yellow(`Stopping existing container aifabrix-${appName}...`));
62
68
  await execAsync(`docker stop aifabrix-${appName}`);
63
69
  await execAsync(`docker rm aifabrix-${appName}`);
64
- console.log(chalk.green(`✓ Container aifabrix-${appName} stopped and removed`));
70
+ logger.log(chalk.green(`✓ Container aifabrix-${appName} stopped and removed`));
65
71
  } catch (error) {
66
72
  // Container might not exist, which is fine
67
- console.log(chalk.gray(`Container aifabrix-${appName} was not running`));
73
+ logger.log(chalk.gray(`Container aifabrix-${appName} was not running`));
68
74
  }
69
75
  }
70
76
 
@@ -84,87 +90,227 @@ async function checkPortAvailable(port) {
84
90
  }
85
91
 
86
92
  /**
87
- * Generates Docker Compose configuration from template
93
+ * Extracts image name from configuration (same logic as build.js)
94
+ * @param {Object} config - Application configuration
95
+ * @param {string} appName - Application name (fallback)
96
+ * @returns {string} Image name
97
+ */
98
+ function getImageName(config, appName) {
99
+ return composeGenerator.getImageName(config, appName);
100
+ }
101
+
102
+ /**
103
+ * Validates app name and loads configuration
104
+ * @async
105
+ * @param {string} appName - Application name
106
+ * @returns {Promise<Object>} Application configuration
107
+ * @throws {Error} If validation fails
108
+ */
109
+ async function validateAppConfiguration(appName) {
110
+ // Validate app name
111
+ if (!appName || typeof appName !== 'string') {
112
+ throw new Error('Application name is required');
113
+ }
114
+
115
+ // Check if we're running from inside the builder directory
116
+ const currentDir = process.cwd();
117
+ const normalizedPath = currentDir.replace(/\\/g, '/');
118
+ const expectedBuilderPath = `builder/${appName}`;
119
+
120
+ // If inside builder/{appName}, suggest moving to project root
121
+ if (normalizedPath.endsWith(expectedBuilderPath)) {
122
+ const projectRoot = path.resolve(currentDir, '../..');
123
+ throw new Error(
124
+ 'You\'re running from inside the builder directory.\n' +
125
+ `Current directory: ${currentDir}\n` +
126
+ 'Please change to the project root and try again:\n' +
127
+ ` cd ${projectRoot}\n` +
128
+ ` aifabrix run ${appName}`
129
+ );
130
+ }
131
+
132
+ // Load and validate app configuration
133
+ const configPath = path.join(process.cwd(), 'builder', appName, 'variables.yaml');
134
+ if (!fsSync.existsSync(configPath)) {
135
+ const expectedDir = path.join(currentDir, 'builder', appName);
136
+ throw new Error(
137
+ `Application configuration not found: ${configPath}\n` +
138
+ `Current directory: ${currentDir}\n` +
139
+ `Expected location: ${expectedDir}\n` +
140
+ 'Make sure you\'re running from the project root (where \'builder\' directory exists)\n' +
141
+ `Run 'aifabrix create ${appName}' first if configuration doesn't exist`
142
+ );
143
+ }
144
+
145
+ const configContent = fsSync.readFileSync(configPath, 'utf8');
146
+ const config = yaml.load(configContent);
147
+
148
+ // Validate configuration
149
+ const validation = await validator.validateApplication(appName);
150
+ if (!validation.valid) {
151
+ const allErrors = [];
152
+
153
+ if (validation.variables && validation.variables.errors && validation.variables.errors.length > 0) {
154
+ allErrors.push('variables.yaml:');
155
+ allErrors.push(...validation.variables.errors.map(err => ` ${err}`));
156
+ }
157
+
158
+ if (validation.rbac && validation.rbac.errors && validation.rbac.errors.length > 0) {
159
+ allErrors.push('rbac.yaml:');
160
+ allErrors.push(...validation.rbac.errors.map(err => ` ${err}`));
161
+ }
162
+
163
+ if (validation.env && validation.env.errors && validation.env.errors.length > 0) {
164
+ allErrors.push('env.template:');
165
+ allErrors.push(...validation.env.errors.map(err => ` ${err}`));
166
+ }
167
+
168
+ if (allErrors.length === 0) {
169
+ throw new Error('Configuration validation failed');
170
+ }
171
+
172
+ throw new Error(`Configuration validation failed:\n${allErrors.join('\n')}`);
173
+ }
174
+
175
+ return config;
176
+ }
177
+
178
+ /**
179
+ * Checks prerequisites: Docker image and infrastructure
180
+ * @async
88
181
  * @param {string} appName - Application name
89
182
  * @param {Object} config - Application configuration
90
- * @param {Object} options - Run options
91
- * @returns {Promise<string>} Generated compose content
183
+ * @throws {Error} If prerequisites are not met
92
184
  */
93
- async function generateDockerCompose(appName, config, options) {
94
- const language = config.build?.language || config.language || 'typescript';
95
- const templatePath = path.join(__dirname, '..', 'templates', language, 'docker-compose.hbs');
96
- if (!fsSync.existsSync(templatePath)) {
97
- throw new Error(`Docker Compose template not found for language: ${language}`);
185
+ async function checkPrerequisites(appName, config) {
186
+ // Extract image name from configuration (same logic as build process)
187
+ const imageName = getImageName(config, appName);
188
+ const imageTag = config.image?.tag || 'latest';
189
+ const fullImageName = `${imageName}:${imageTag}`;
190
+
191
+ // Check if Docker image exists
192
+ logger.log(chalk.blue(`Checking if image ${fullImageName} exists...`));
193
+ const imageExists = await checkImageExists(imageName, imageTag);
194
+ if (!imageExists) {
195
+ throw new Error(`Docker image ${fullImageName} not found\nRun 'aifabrix build ${appName}' first`);
98
196
  }
197
+ logger.log(chalk.green(`✓ Image ${fullImageName} found`));
99
198
 
100
- const templateContent = fsSync.readFileSync(templatePath, 'utf8');
101
- const template = handlebars.compile(templateContent);
102
-
103
- const port = options.port || config.build?.localPort || config.port || 3000;
104
-
105
- const templateData = {
106
- app: {
107
- key: appName,
108
- name: config.displayName || appName
109
- },
110
- image: {
111
- name: appName,
112
- tag: 'latest'
113
- },
114
- port: config.port || 3000,
115
- build: {
116
- localPort: port
117
- },
118
- healthCheck: {
119
- path: config.healthCheck?.path || '/health',
120
- interval: config.healthCheck?.interval || 30
121
- },
122
- requiresDatabase: config.services?.database || false,
123
- requiresStorage: config.services?.storage || false,
124
- requiresRedis: config.services?.redis || false,
125
- mountVolume: path.join(process.cwd(), 'data', appName),
126
- databases: config.databases || []
127
- };
199
+ // Check infrastructure health
200
+ logger.log(chalk.blue('Checking infrastructure health...'));
201
+ const infraHealth = await infra.checkInfraHealth();
202
+ const unhealthyServices = Object.entries(infraHealth)
203
+ .filter(([_, status]) => status !== 'healthy')
204
+ .map(([service, _]) => service);
128
205
 
129
- return template(templateData);
206
+ if (unhealthyServices.length > 0) {
207
+ throw new Error(`Infrastructure services not healthy: ${unhealthyServices.join(', ')}\nRun 'aifabrix up' first`);
208
+ }
209
+ logger.log(chalk.green('✓ Infrastructure is running'));
130
210
  }
131
211
 
132
212
  /**
133
- * Waits for container health check to pass
213
+ * Prepares environment: ensures .env file and generates Docker Compose
214
+ * @async
134
215
  * @param {string} appName - Application name
135
- * @param {number} timeout - Timeout in seconds
216
+ * @param {Object} config - Application configuration
217
+ * @param {Object} options - Run options
218
+ * @returns {Promise<string>} Path to generated compose file
136
219
  */
137
- async function waitForHealthCheck(appName, timeout = 60) {
138
- const maxAttempts = timeout / 2; // Check every 2 seconds
139
- let attempts = 0;
220
+ async function prepareEnvironment(appName, config, options) {
221
+ // Ensure .env file exists with 'docker' environment context (for running in Docker)
222
+ const envPath = path.join(process.cwd(), 'builder', appName, '.env');
223
+ if (!fsSync.existsSync(envPath)) {
224
+ logger.log(chalk.yellow('Generating .env file from template...'));
225
+ await secrets.generateEnvFile(appName, null, 'docker');
226
+ } else {
227
+ // Re-generate with 'docker' context to ensure correct hostnames for Docker
228
+ logger.log(chalk.blue('Updating .env file for Docker environment...'));
229
+ await secrets.generateEnvFile(appName, null, 'docker');
230
+ }
140
231
 
141
- while (attempts < maxAttempts) {
142
- try {
143
- const { stdout } = await execAsync(`docker inspect --format='{{.State.Health.Status}}' aifabrix-${appName}`);
144
- const status = stdout.trim();
145
-
146
- if (status === 'healthy') {
147
- return;
148
- } else if (status === 'unhealthy') {
149
- throw new Error(`Container aifabrix-${appName} is unhealthy`);
150
- }
151
-
152
- attempts++;
153
- if (attempts < maxAttempts) {
154
- console.log(chalk.yellow(`Waiting for health check... (${attempts}/${maxAttempts})`));
155
- await new Promise(resolve => setTimeout(resolve, 2000));
156
- }
157
- } catch (error) {
158
- attempts++;
159
- if (attempts < maxAttempts) {
160
- await new Promise(resolve => setTimeout(resolve, 2000));
161
- }
232
+ // Also ensure .env file in apps/ directory is updated (for Docker build context)
233
+ const variablesPath = path.join(process.cwd(), 'builder', appName, 'variables.yaml');
234
+ if (fsSync.existsSync(variablesPath)) {
235
+ const variablesContent = fsSync.readFileSync(variablesPath, 'utf8');
236
+ const variables = yaml.load(variablesContent);
237
+
238
+ if (variables?.build?.envOutputPath && variables.build.envOutputPath !== null) {
239
+ // The generateEnvFile already copies to apps/, but ensure it's using docker context
240
+ logger.log(chalk.blue('Ensuring .env file in apps/ directory is updated for Docker...'));
241
+ await secrets.generateEnvFile(appName, null, 'docker');
162
242
  }
163
243
  }
164
244
 
165
- throw new Error(`Health check timeout after ${timeout} seconds`);
245
+ // Generate Docker Compose configuration
246
+ logger.log(chalk.blue('Generating Docker Compose configuration...'));
247
+ const composeContent = await composeGenerator.generateDockerCompose(appName, config, options);
248
+ // Write compose file to temporary location
249
+ const tempComposePath = path.join(process.cwd(), 'builder', appName, 'docker-compose.yaml');
250
+ await fs.writeFile(tempComposePath, composeContent);
251
+
252
+ return tempComposePath;
253
+ }
254
+
255
+ /**
256
+ * Starts the container and waits for health check
257
+ * @async
258
+ * @param {string} appName - Application name
259
+ * @param {string} composePath - Path to Docker Compose file
260
+ * @param {number} port - Application port
261
+ * @throws {Error} If container fails to start or become healthy
262
+ */
263
+ async function startContainer(appName, composePath, port, config = null) {
264
+ logger.log(chalk.blue(`Starting ${appName}...`));
265
+
266
+ // Ensure ADMIN_SECRETS_PATH is set for db-init service
267
+ const adminSecretsPath = await infra.ensureAdminSecrets();
268
+
269
+ // Load POSTGRES_PASSWORD from admin-secrets.env
270
+ const adminSecretsContent = fsSync.readFileSync(adminSecretsPath, 'utf8');
271
+ const postgresPasswordMatch = adminSecretsContent.match(/^POSTGRES_PASSWORD=(.+)$/m);
272
+ const postgresPassword = postgresPasswordMatch ? postgresPasswordMatch[1] : '';
273
+
274
+ // Set environment variables for docker-compose
275
+ const env = {
276
+ ...process.env,
277
+ ADMIN_SECRETS_PATH: adminSecretsPath,
278
+ POSTGRES_PASSWORD: postgresPassword
279
+ };
280
+
281
+ await execAsync(`docker-compose -f "${composePath}" up -d`, { env });
282
+ logger.log(chalk.green(`✓ Container aifabrix-${appName} started`));
283
+
284
+ // Wait for health check
285
+ const healthCheckPath = config?.healthCheck?.path || '/health';
286
+ const healthCheckUrl = `http://localhost:${port}${healthCheckPath}`;
287
+ logger.log(chalk.blue(`Waiting for application to be healthy at ${healthCheckUrl}...`));
288
+ await waitForHealthCheck(appName, 90, port, config);
289
+ }
290
+
291
+ /**
292
+ * Displays run status after successful start
293
+ * @param {string} appName - Application name
294
+ * @param {number} port - Application port
295
+ * @param {Object} config - Application configuration
296
+ */
297
+ function displayRunStatus(appName, port, config) {
298
+ const healthCheckPath = config?.healthCheck?.path || '/health';
299
+ const healthCheckUrl = `http://localhost:${port}${healthCheckPath}`;
300
+
301
+ logger.log(chalk.green(`\n✓ App running at http://localhost:${port}`));
302
+ logger.log(chalk.blue(`Health check: ${healthCheckUrl}`));
303
+ logger.log(chalk.gray(`Container: aifabrix-${appName}`));
166
304
  }
167
305
 
306
+ /**
307
+ * Waits for container health check to pass
308
+ * @param {string} appName - Application name
309
+ * @param {number} timeout - Timeout in seconds
310
+ * @param {number} port - Application port (optional, will be detected if not provided)
311
+ * @param {Object} config - Application configuration (optional)
312
+ */
313
+
168
314
  /**
169
315
  * Runs the application locally using Docker
170
316
  * Starts container with proper port mapping and environment
@@ -183,50 +329,16 @@ async function waitForHealthCheck(appName, timeout = 60) {
183
329
  */
184
330
  async function runApp(appName, options = {}) {
185
331
  try {
186
- // Validate app name
187
- if (!appName || typeof appName !== 'string') {
188
- throw new Error('Application name is required');
189
- }
190
-
191
- // Load and validate app configuration
192
- const configPath = path.join(process.cwd(), 'builder', appName, 'variables.yaml');
193
- if (!fsSync.existsSync(configPath)) {
194
- throw new Error(`Application configuration not found: ${configPath}\nRun 'aifabrix create ${appName}' first`);
195
- }
196
-
197
- const configContent = fsSync.readFileSync(configPath, 'utf8');
198
- const config = yaml.load(configContent);
199
-
200
- // Validate configuration
201
- const validation = await validator.validateApplication(appName);
202
- if (!validation.valid) {
203
- throw new Error(`Configuration validation failed:\n${validation.variables.errors.join('\n')}`);
204
- }
205
-
206
- // Check if Docker image exists
207
- console.log(chalk.blue(`Checking if image ${appName}:latest exists...`));
208
- const imageExists = await checkImageExists(appName);
209
- if (!imageExists) {
210
- throw new Error(`Docker image ${appName}:latest not found\nRun 'aifabrix build ${appName}' first`);
211
- }
212
- console.log(chalk.green(`✓ Image ${appName}:latest found`));
332
+ // Validate app name and load configuration
333
+ const config = await validateAppConfiguration(appName);
213
334
 
214
- // Check infrastructure health
215
- console.log(chalk.blue('Checking infrastructure health...'));
216
- const infraHealth = await infra.checkInfraHealth();
217
- const unhealthyServices = Object.entries(infraHealth)
218
- .filter(([_, status]) => status !== 'healthy')
219
- .map(([service, _]) => service);
220
-
221
- if (unhealthyServices.length > 0) {
222
- throw new Error(`Infrastructure services not healthy: ${unhealthyServices.join(', ')}\nRun 'aifabrix up' first`);
223
- }
224
- console.log(chalk.green('✓ Infrastructure is running'));
335
+ // Check prerequisites: image and infrastructure
336
+ await checkPrerequisites(appName, config);
225
337
 
226
338
  // Check if container is already running
227
339
  const containerRunning = await checkContainerRunning(appName);
228
340
  if (containerRunning) {
229
- console.log(chalk.yellow(`Container aifabrix-${appName} is already running`));
341
+ logger.log(chalk.yellow(`Container aifabrix-${appName} is already running`));
230
342
  await stopAndRemoveContainer(appName);
231
343
  }
232
344
 
@@ -237,42 +349,21 @@ async function runApp(appName, options = {}) {
237
349
  throw new Error(`Port ${port} is already in use. Try --port <alternative>`);
238
350
  }
239
351
 
240
- // Ensure .env file exists
241
- const envPath = path.join(process.cwd(), 'builder', appName, '.env');
242
- if (!fsSync.existsSync(envPath)) {
243
- console.log(chalk.yellow('Generating .env file from template...'));
244
- await secrets.generateEnvFile(appName);
245
- }
246
-
247
- // Generate Docker Compose configuration
248
- console.log(chalk.blue('Generating Docker Compose configuration...'));
249
- const composeContent = await generateDockerCompose(appName, config, options);
250
- // Write compose file to temporary location
251
- const tempComposePath = path.join(process.cwd(), 'builder', appName, 'docker-compose.yaml');
252
- await fs.writeFile(tempComposePath, composeContent);
352
+ // Prepare environment: ensure .env file and generate Docker Compose
353
+ const tempComposePath = await prepareEnvironment(appName, config, options);
253
354
 
254
355
  try {
255
- // Start container
256
- console.log(chalk.blue(`Starting ${appName}...`));
257
- await execAsync(`docker-compose -f "${tempComposePath}" up -d`);
258
- console.log(chalk.green(`✓ Container aifabrix-${appName} started`));
259
-
260
- // Wait for health check
261
- console.log(chalk.blue('Waiting for application to be healthy...'));
262
- await waitForHealthCheck(appName);
356
+ // Start container and wait for health check
357
+ await startContainer(appName, tempComposePath, port, config);
263
358
 
264
359
  // Display success message
265
- console.log(chalk.green(`\n✓ App running at http://localhost:${port}`));
266
- console.log(chalk.gray(`Container: aifabrix-${appName}`));
267
- console.log(chalk.gray('Health check: /health'));
268
-
269
- } finally {
270
- // Clean up temporary compose file
271
- try {
272
- await fs.unlink(tempComposePath);
273
- } catch (error) {
274
- // Ignore cleanup errors
275
- }
360
+ displayRunStatus(appName, port, config);
361
+
362
+ } catch (error) {
363
+ // Keep the compose file for debugging - don't delete on error
364
+ logger.log(chalk.yellow(`\n⚠️ Compose file preserved at: ${tempComposePath}`));
365
+ logger.log(chalk.yellow(' Review the file to debug issues'));
366
+ throw error;
276
367
  }
277
368
 
278
369
  } catch (error) {
@@ -286,6 +377,6 @@ module.exports = {
286
377
  checkContainerRunning,
287
378
  stopAndRemoveContainer,
288
379
  checkPortAvailable,
289
- generateDockerCompose,
380
+ generateDockerCompose: composeGenerator.generateDockerCompose,
290
381
  waitForHealthCheck
291
382
  };