@aifabrix/builder 2.1.6 → 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.
Files changed (38) hide show
  1. package/lib/app-deploy.js +73 -29
  2. package/lib/app-list.js +132 -0
  3. package/lib/app-readme.js +11 -4
  4. package/lib/app-register.js +435 -0
  5. package/lib/app-rotate-secret.js +164 -0
  6. package/lib/app-run.js +98 -84
  7. package/lib/app.js +13 -0
  8. package/lib/audit-logger.js +195 -15
  9. package/lib/build.js +57 -37
  10. package/lib/cli.js +90 -8
  11. package/lib/commands/app.js +8 -391
  12. package/lib/commands/login.js +130 -36
  13. package/lib/config.js +257 -4
  14. package/lib/deployer.js +221 -183
  15. package/lib/infra.js +177 -112
  16. package/lib/secrets.js +85 -99
  17. package/lib/utils/api-error-handler.js +465 -0
  18. package/lib/utils/api.js +165 -16
  19. package/lib/utils/auth-headers.js +84 -0
  20. package/lib/utils/build-copy.js +144 -0
  21. package/lib/utils/cli-utils.js +21 -0
  22. package/lib/utils/compose-generator.js +43 -14
  23. package/lib/utils/deployment-errors.js +90 -0
  24. package/lib/utils/deployment-validation.js +60 -0
  25. package/lib/utils/dev-config.js +83 -0
  26. package/lib/utils/env-template.js +30 -10
  27. package/lib/utils/health-check.js +18 -1
  28. package/lib/utils/infra-containers.js +101 -0
  29. package/lib/utils/local-secrets.js +0 -2
  30. package/lib/utils/secrets-path.js +18 -21
  31. package/lib/utils/secrets-utils.js +206 -0
  32. package/lib/utils/token-manager.js +381 -0
  33. package/package.json +1 -1
  34. package/templates/applications/README.md.hbs +155 -23
  35. package/templates/applications/miso-controller/Dockerfile +7 -119
  36. package/templates/infra/compose.yaml.hbs +93 -0
  37. package/templates/python/docker-compose.hbs +25 -17
  38. package/templates/typescript/docker-compose.hbs +25 -17
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Deployment Error Handling Utilities
3
+ *
4
+ * Handles deployment errors with security-aware messages and audit logging.
5
+ *
6
+ * @fileoverview Deployment error handling functions
7
+ * @author AI Fabrix Team
8
+ * @version 2.0.0
9
+ */
10
+
11
+ const auditLogger = require('../audit-logger');
12
+ const { parseErrorResponse } = require('./api-error-handler');
13
+
14
+ /**
15
+ * Handles deployment errors with security-aware messages
16
+ *
17
+ * @param {Error} error - Error to handle
18
+ * @returns {Object} Structured error information
19
+ */
20
+ function handleDeploymentError(error) {
21
+ const safeError = {
22
+ message: error.message,
23
+ code: error.code || 'UNKNOWN',
24
+ timeout: error.code === 'ECONNABORTED',
25
+ status: error.status || error.response?.status,
26
+ data: error.data || error.response?.data
27
+ };
28
+
29
+ // Mask sensitive information in error messages
30
+ safeError.message = auditLogger.maskSensitiveData(safeError.message);
31
+
32
+ return safeError;
33
+ }
34
+
35
+ /**
36
+ * Unified error handler for deployment errors
37
+ * Handles audit logging, error formatting, and user-friendly messages
38
+ * @param {Error} error - Error object
39
+ * @param {string} appName - Application name for audit logging
40
+ * @param {string} url - Controller URL for audit logging
41
+ * @param {boolean} [alreadyLogged=false] - Whether error has already been logged
42
+ * @throws {Error} User-friendly error message
43
+ */
44
+ async function handleDeploymentErrors(error, appName, url, alreadyLogged = false) {
45
+ // Log to audit log if not already logged
46
+ if (!alreadyLogged) {
47
+ try {
48
+ await auditLogger.logDeploymentFailure(appName, url, error);
49
+ } catch (logError) {
50
+ // Don't fail if audit logging fails, but log to console
51
+ // eslint-disable-next-line no-console
52
+ console.error(`[AUDIT LOG ERROR] Failed to log deployment failure: ${logError.message}`);
53
+ }
54
+ }
55
+
56
+ const safeError = handleDeploymentError(error);
57
+
58
+ // Extract error data from axios response
59
+ let errorData = safeError.data;
60
+ if (error.response && error.response.data !== undefined) {
61
+ errorData = error.response.data;
62
+ }
63
+
64
+ // Ensure errorData is not undefined before parsing
65
+ // If errorData is undefined, use the error message instead
66
+ const errorResponse = errorData !== undefined ? errorData : safeError.message;
67
+
68
+ // Determine if this is a network error
69
+ const isNetworkError = safeError.code === 'ECONNREFUSED' ||
70
+ safeError.code === 'ENOTFOUND' ||
71
+ safeError.code === 'ECONNABORTED' ||
72
+ safeError.timeout;
73
+
74
+ // Parse error using error handler
75
+ const parsedError = parseErrorResponse(errorResponse, safeError.status || 0, isNetworkError);
76
+
77
+ // Throw clean error message (without emoji) - CLI will format it
78
+ const formattedError = new Error(parsedError.message);
79
+ formattedError.formatted = parsedError.formatted;
80
+ formattedError.status = safeError.status;
81
+ formattedError.data = parsedError.data;
82
+ formattedError._logged = true; // Mark as logged to prevent double-logging
83
+ throw formattedError;
84
+ }
85
+
86
+ module.exports = {
87
+ handleDeploymentError,
88
+ handleDeploymentErrors
89
+ };
90
+
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Deployment Validation Utilities
3
+ *
4
+ * Validates deployment configuration inputs with ISO 27001 security measures.
5
+ *
6
+ * @fileoverview Deployment validation functions
7
+ * @author AI Fabrix Team
8
+ * @version 2.0.0
9
+ */
10
+
11
+ /**
12
+ * Validates and sanitizes controller URL
13
+ * Enforces HTTPS-only communication for security
14
+ *
15
+ * @param {string} url - Controller URL to validate
16
+ * @throws {Error} If URL is invalid or uses HTTP
17
+ */
18
+ function validateControllerUrl(url) {
19
+ if (!url || typeof url !== 'string') {
20
+ throw new Error('Controller URL is required and must be a string');
21
+ }
22
+
23
+ // Must use HTTPS for security (allow http://localhost for local development)
24
+ if (!url.startsWith('https://') && !url.startsWith('http://localhost')) {
25
+ throw new Error('Controller URL must use HTTPS (https://) or http://localhost');
26
+ }
27
+
28
+ // Basic URL format validation
29
+ const urlPattern = /^(https?):\/\/[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*(localhost)?(:[0-9]+)?(\/.*)?$/;
30
+ if (!urlPattern.test(url)) {
31
+ throw new Error('Invalid controller URL format');
32
+ }
33
+
34
+ // Remove trailing slash if present
35
+ return url.replace(/\/$/, '');
36
+ }
37
+
38
+ /**
39
+ * Validates environment key
40
+ * @param {string} envKey - Environment key to validate
41
+ * @throws {Error} If environment key is invalid
42
+ */
43
+ function validateEnvironmentKey(envKey) {
44
+ if (!envKey || typeof envKey !== 'string') {
45
+ throw new Error('Environment key is required and must be a string');
46
+ }
47
+
48
+ const validEnvironments = ['miso', 'dev', 'tst', 'pro'];
49
+ if (!validEnvironments.includes(envKey.toLowerCase())) {
50
+ throw new Error(`Invalid environment key: ${envKey}. Must be one of: ${validEnvironments.join(', ')}`);
51
+ }
52
+
53
+ return envKey.toLowerCase();
54
+ }
55
+
56
+ module.exports = {
57
+ validateControllerUrl,
58
+ validateEnvironmentKey
59
+ };
60
+
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Developer Configuration Utilities
3
+ *
4
+ * This module provides utilities for calculating developer-specific ports
5
+ * based on developer ID. Ports are offset by (developer-id * 100).
6
+ *
7
+ * @fileoverview Developer configuration and port calculation utilities
8
+ * @author AI Fabrix Team
9
+ * @version 2.0.0
10
+ */
11
+
12
+ /**
13
+ * Base ports for infrastructure and applications
14
+ * These are the default ports before any developer offset
15
+ */
16
+ const BASE_PORTS = {
17
+ app: 3000,
18
+ postgres: 5432,
19
+ redis: 6379,
20
+ pgadmin: 5050,
21
+ redisCommander: 8081
22
+ };
23
+
24
+ /**
25
+ * Calculates developer-specific ports based on developer ID
26
+ * Formula: basePort + (developerId * 100)
27
+ * Developer ID: 0 = default infra (base ports), > 0 = developer-specific (offset ports)
28
+ *
29
+ * @function getDevPorts
30
+ * @param {number} developerId - Developer ID (0 = default infra, 1, 2, 3, etc. = developer-specific)
31
+ * @returns {Object} Object with calculated ports for all services
32
+ *
33
+ * @example
34
+ * const ports = getDevPorts(0); // Default infra
35
+ * // Returns: { app: 3000, postgres: 5432, redis: 6379, pgadmin: 5050, redisCommander: 8081 }
36
+ * const ports = getDevPorts(1); // Developer-specific
37
+ * // Returns: { app: 3100, postgres: 5532, redis: 6479, pgadmin: 5150, redisCommander: 8181 }
38
+ */
39
+ function getDevPorts(developerId) {
40
+ // Validate type first - must be a number
41
+ if (typeof developerId !== 'number') {
42
+ throw new Error('Developer ID must be a positive number');
43
+ }
44
+
45
+ // Handle NaN, undefined, null - throw error (don't default)
46
+ if (isNaN(developerId) || developerId === undefined || developerId === null) {
47
+ throw new Error('Developer ID must be a positive number');
48
+ }
49
+
50
+ if (developerId < 0 || !Number.isInteger(developerId)) {
51
+ throw new Error('Developer ID must be a positive number');
52
+ }
53
+
54
+ // Developer ID 0 = default infra (base ports, no offset)
55
+ if (developerId === 0) {
56
+ return { ...BASE_PORTS };
57
+ }
58
+
59
+ // Developer ID > 0 = developer-specific (add offset)
60
+ const offset = developerId * 100;
61
+
62
+ return {
63
+ app: BASE_PORTS.app + offset,
64
+ postgres: BASE_PORTS.postgres + offset,
65
+ redis: BASE_PORTS.redis + offset,
66
+ pgadmin: BASE_PORTS.pgadmin + offset,
67
+ redisCommander: BASE_PORTS.redisCommander + offset
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Gets base ports (for reference/documentation)
73
+ * @returns {Object} Base ports object
74
+ */
75
+ function getBasePorts() {
76
+ return { ...BASE_PORTS };
77
+ }
78
+
79
+ module.exports = {
80
+ getDevPorts,
81
+ getBasePorts
82
+ };
83
+
@@ -15,14 +15,15 @@ const chalk = require('chalk');
15
15
  const logger = require('../utils/logger');
16
16
 
17
17
  /**
18
- * Updates env.template to add MISO_CLIENTID and MISO_CLIENTSECRET entries
18
+ * Updates env.template to add MISO_CLIENTID, MISO_CLIENTSECRET, and MISO_CONTROLLER_URL entries
19
19
  * @async
20
20
  * @param {string} appKey - Application key
21
21
  * @param {string} clientIdKey - Secret key for client ID (e.g., 'myapp-client-idKeyVault')
22
22
  * @param {string} clientSecretKey - Secret key for client secret (e.g., 'myapp-client-secretKeyVault')
23
+ * @param {string} controllerUrl - Controller URL (e.g., 'http://localhost:3010' or 'https://controller.aifabrix.ai')
23
24
  * @returns {Promise<void>} Resolves when template is updated
24
25
  */
25
- async function updateEnvTemplate(appKey, clientIdKey, clientSecretKey) {
26
+ async function updateEnvTemplate(appKey, clientIdKey, clientSecretKey, controllerUrl) {
26
27
  const envTemplatePath = path.join(process.cwd(), 'builder', appKey, 'env.template');
27
28
 
28
29
  if (!fsSync.existsSync(envTemplatePath)) {
@@ -33,19 +34,39 @@ async function updateEnvTemplate(appKey, clientIdKey, clientSecretKey) {
33
34
  try {
34
35
  let content = await fs.readFile(envTemplatePath, 'utf8');
35
36
 
36
- // Check if MISO_CLIENTID already exists
37
+ // Check if entries already exist
37
38
  const hasClientId = /^MISO_CLIENTID\s*=/m.test(content);
38
39
  const hasClientSecret = /^MISO_CLIENTSECRET\s*=/m.test(content);
40
+ const hasControllerUrl = /^MISO_CONTROLLER_URL\s*=/m.test(content);
39
41
 
40
- if (hasClientId && hasClientSecret) {
41
- // Update existing entries
42
+ // Update existing entries
43
+ if (hasClientId) {
42
44
  content = content.replace(/^MISO_CLIENTID\s*=.*$/m, `MISO_CLIENTID=kv://${clientIdKey}`);
45
+ }
46
+
47
+ if (hasClientSecret) {
43
48
  content = content.replace(/^MISO_CLIENTSECRET\s*=.*$/m, `MISO_CLIENTSECRET=kv://${clientSecretKey}`);
44
- } else {
45
- // Add new section if not present
49
+ }
50
+
51
+ if (hasControllerUrl) {
52
+ content = content.replace(/^MISO_CONTROLLER_URL\s*=.*$/m, `MISO_CONTROLLER_URL=${controllerUrl}`);
53
+ }
54
+
55
+ // Add missing entries
56
+ if (!hasClientId || !hasClientSecret || !hasControllerUrl) {
57
+ const missingEntries = [];
58
+ if (!hasClientId) {
59
+ missingEntries.push(`MISO_CLIENTID=kv://${clientIdKey}`);
60
+ }
61
+ if (!hasClientSecret) {
62
+ missingEntries.push(`MISO_CLIENTSECRET=kv://${clientSecretKey}`);
63
+ }
64
+ if (!hasControllerUrl) {
65
+ missingEntries.push(`MISO_CONTROLLER_URL=${controllerUrl}`);
66
+ }
67
+
46
68
  const misoSection = `# MISO Application Client Credentials (per application)
47
- MISO_CLIENTID=kv://${clientIdKey}
48
- MISO_CLIENTSECRET=kv://${clientSecretKey}
69
+ ${missingEntries.join('\n')}
49
70
  `;
50
71
 
51
72
  // Try to find a good place to insert (after last section or at end)
@@ -60,7 +81,6 @@ MISO_CLIENTSECRET=kv://${clientSecretKey}
60
81
  }
61
82
 
62
83
  await fs.writeFile(envTemplatePath, content, 'utf8');
63
- logger.log(chalk.green(`✓ Updated env.template for ${appKey}`));
64
84
  } catch (error) {
65
85
  logger.warn(chalk.yellow(`⚠️ Could not update env.template: ${error.message}`));
66
86
  }
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  const http = require('http');
12
+ const net = require('net');
12
13
  const chalk = require('chalk');
13
14
  const { exec } = require('child_process');
14
15
  const { promisify } = require('util');
@@ -300,8 +301,24 @@ async function waitForHealthCheck(appName, timeout = 90, port = null, config = n
300
301
  throw new Error(`Health check timeout after ${timeout} seconds`);
301
302
  }
302
303
 
304
+ /**
305
+ * Checks if port is available
306
+ * @param {number} port - Port number to check
307
+ * @returns {Promise<boolean>} True if port is available
308
+ */
309
+ async function checkPortAvailable(port) {
310
+ return new Promise((resolve) => {
311
+ const server = net.createServer();
312
+ server.listen(port, () => {
313
+ server.close(() => resolve(true));
314
+ });
315
+ server.on('error', () => resolve(false));
316
+ });
317
+ }
318
+
303
319
  module.exports = {
304
320
  waitForHealthCheck,
305
- checkHealthEndpoint
321
+ checkHealthEndpoint,
322
+ checkPortAvailable
306
323
  };
307
324
 
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Infrastructure Container Utilities
3
+ *
4
+ * This module provides helper functions for finding and checking
5
+ * infrastructure containers. Used by the main infra.js module.
6
+ *
7
+ * @fileoverview Container utilities for infrastructure management
8
+ * @author AI Fabrix Team
9
+ * @version 2.0.0
10
+ */
11
+
12
+ const { exec } = require('child_process');
13
+ const { promisify } = require('util');
14
+ const config = require('../config');
15
+
16
+ const execAsync = promisify(exec);
17
+
18
+ /**
19
+ * Finds container by name pattern
20
+ * @private
21
+ * @async
22
+ * @param {string} serviceName - Service name
23
+ * @param {number} [devId] - Developer ID (optional, will be loaded from config if not provided)
24
+ * @returns {Promise<string|null>} Container name or null if not found
25
+ */
26
+ async function findContainer(serviceName, devId = null) {
27
+ try {
28
+ const developerId = devId || await config.getDeveloperId();
29
+ // Dev 0: aifabrix-{serviceName}, Dev > 0: aifabrix-dev{id}-{serviceName}
30
+ const containerNamePattern = developerId === 0
31
+ ? `aifabrix-${serviceName}`
32
+ : `aifabrix-dev${developerId}-${serviceName}`;
33
+ let { stdout } = await execAsync(`docker ps --filter "name=${containerNamePattern}" --format "{{.Names}}"`);
34
+ let containerName = stdout.trim();
35
+ if (!containerName) {
36
+ // Fallback to old naming patterns for backward compatibility
37
+ ({ stdout } = await execAsync(`docker ps --filter "name=infra-${serviceName}" --format "{{.Names}}"`));
38
+ containerName = stdout.trim();
39
+ if (!containerName) {
40
+ ({ stdout } = await execAsync(`docker ps --filter "name=aifabrix-${serviceName}" --format "{{.Names}}"`));
41
+ containerName = stdout.trim();
42
+ }
43
+ }
44
+ return containerName;
45
+ } catch (error) {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Checks health status for a service with health checks
52
+ * @private
53
+ * @async
54
+ * @param {string} serviceName - Service name
55
+ * @param {number} [devId] - Developer ID (optional, will be loaded from config if not provided)
56
+ * @returns {Promise<string>} Health status
57
+ */
58
+ async function checkServiceWithHealthCheck(serviceName, devId = null) {
59
+ try {
60
+ const containerName = await findContainer(serviceName, devId);
61
+ if (!containerName) {
62
+ return 'unknown';
63
+ }
64
+ const { stdout } = await execAsync(`docker inspect --format='{{.State.Health.Status}}' ${containerName}`);
65
+ const status = stdout.trim().replace(/['"]/g, '');
66
+ // Accept both 'healthy' and 'starting' as healthy (starting means it's initializing)
67
+ return (status === 'healthy' || status === 'starting') ? 'healthy' : status;
68
+ } catch (error) {
69
+ return 'unknown';
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Checks health status for a service without health checks
75
+ * @private
76
+ * @async
77
+ * @param {string} serviceName - Service name
78
+ * @param {number} [devId] - Developer ID (optional, will be loaded from config if not provided)
79
+ * @returns {Promise<string>} Health status
80
+ */
81
+ async function checkServiceWithoutHealthCheck(serviceName, devId = null) {
82
+ try {
83
+ const containerName = await findContainer(serviceName, devId);
84
+ if (!containerName) {
85
+ return 'unknown';
86
+ }
87
+ const { stdout } = await execAsync(`docker inspect --format='{{.State.Status}}' ${containerName}`);
88
+ const status = stdout.trim().replace(/['"]/g, '');
89
+ // Treat 'running' or 'healthy' as 'healthy' for services without health checks
90
+ return (status === 'running' || status === 'healthy') ? 'healthy' : 'unhealthy';
91
+ } catch (error) {
92
+ return 'unknown';
93
+ }
94
+ }
95
+
96
+ module.exports = {
97
+ findContainer,
98
+ checkServiceWithHealthCheck,
99
+ checkServiceWithoutHealthCheck
100
+ };
101
+
@@ -12,7 +12,6 @@ const fs = require('fs');
12
12
  const path = require('path');
13
13
  const yaml = require('js-yaml');
14
14
  const os = require('os');
15
- const chalk = require('chalk');
16
15
  const logger = require('../utils/logger');
17
16
 
18
17
  /**
@@ -76,7 +75,6 @@ async function saveLocalSecret(key, value) {
76
75
  });
77
76
 
78
77
  fs.writeFileSync(secretsPath, yamlContent, { mode: 0o600 });
79
- logger.log(chalk.green(`✓ Saved secret ${key} to ${secretsPath}`));
80
78
  }
81
79
 
82
80
  /**
@@ -54,26 +54,30 @@ function resolveSecretsPath(secretsPath) {
54
54
  }
55
55
 
56
56
  /**
57
- * Determines the actual secrets file path that loadSecrets would use
57
+ * Determines the actual secrets file paths that loadSecrets would use
58
58
  * Mirrors the cascading lookup logic from loadSecrets
59
59
  * @function getActualSecretsPath
60
60
  * @param {string} [secretsPath] - Path to secrets file (optional)
61
61
  * @param {string} [appName] - Application name (optional, for variables.yaml lookup)
62
- * @returns {string} Actual secrets file path that would be used
62
+ * @returns {Object} Object with userPath and buildPath (if configured)
63
+ * @returns {string} returns.userPath - User's secrets file path (~/.aifabrix/secrets.local.yaml)
64
+ * @returns {string|null} returns.buildPath - App's build.secrets file path (if configured in variables.yaml)
63
65
  */
64
66
  function getActualSecretsPath(secretsPath, appName) {
65
67
  // If explicit path provided, use it (backward compatibility)
66
68
  if (secretsPath) {
67
- return resolveSecretsPath(secretsPath);
69
+ const resolvedPath = resolveSecretsPath(secretsPath);
70
+ return {
71
+ userPath: resolvedPath,
72
+ buildPath: null
73
+ };
68
74
  }
69
75
 
70
76
  // Cascading lookup: user's file first
71
77
  const userSecretsPath = path.join(os.homedir(), '.aifabrix', 'secrets.local.yaml');
72
- if (fs.existsSync(userSecretsPath)) {
73
- return userSecretsPath;
74
- }
75
78
 
76
- // Then check build.secrets from variables.yaml if appName provided
79
+ // Check build.secrets from variables.yaml if appName provided
80
+ let buildSecretsPath = null;
77
81
  if (appName) {
78
82
  const variablesPath = path.join(process.cwd(), 'builder', appName, 'variables.yaml');
79
83
  if (fs.existsSync(variablesPath)) {
@@ -82,29 +86,22 @@ function getActualSecretsPath(secretsPath, appName) {
82
86
  const variables = yaml.load(variablesContent);
83
87
 
84
88
  if (variables?.build?.secrets) {
85
- const buildSecretsPath = path.resolve(
89
+ buildSecretsPath = path.resolve(
86
90
  path.dirname(variablesPath),
87
91
  variables.build.secrets
88
92
  );
89
-
90
- if (fs.existsSync(buildSecretsPath)) {
91
- return buildSecretsPath;
92
- }
93
93
  }
94
94
  } catch (error) {
95
- // Ignore errors, continue to next check
95
+ // Ignore errors, continue
96
96
  }
97
97
  }
98
98
  }
99
99
 
100
- // If still no secrets found, try default location
101
- const defaultPath = path.join(os.homedir(), '.aifabrix', 'secrets.yaml');
102
- if (fs.existsSync(defaultPath)) {
103
- return defaultPath;
104
- }
105
-
106
- // Return user's file path as default (even if it doesn't exist) for error messages
107
- return userSecretsPath;
100
+ // Return both paths (even if files don't exist) for error messages
101
+ return {
102
+ userPath: userSecretsPath,
103
+ buildPath: buildSecretsPath
104
+ };
108
105
  }
109
106
 
110
107
  module.exports = {