@aifabrix/builder 2.0.2 → 2.0.4

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/secrets.js CHANGED
@@ -13,9 +13,12 @@ const fs = require('fs');
13
13
  const path = require('path');
14
14
  const yaml = require('js-yaml');
15
15
  const os = require('os');
16
- const crypto = require('crypto');
17
16
  const chalk = require('chalk');
18
17
  const logger = require('./utils/logger');
18
+ const {
19
+ generateMissingSecrets,
20
+ createDefaultSecrets
21
+ } = require('./utils/secrets-generator');
19
22
 
20
23
  /**
21
24
  * Loads environment configuration for docker/local context
@@ -42,13 +45,7 @@ function loadEnvConfig() {
42
45
  * // Returns: { 'postgres-passwordKeyVault': 'admin123', ... }
43
46
  */
44
47
  async function loadSecrets(secretsPath) {
45
- let resolvedPath = secretsPath;
46
-
47
- if (!resolvedPath) {
48
- resolvedPath = path.join(os.homedir(), '.aifabrix', 'secrets.yaml');
49
- } else if (secretsPath.startsWith('..')) {
50
- resolvedPath = path.resolve(process.cwd(), secretsPath);
51
- }
48
+ const resolvedPath = resolveSecretsPath(secretsPath);
52
49
 
53
50
  if (!fs.existsSync(resolvedPath)) {
54
51
  throw new Error(`Secrets file not found: ${resolvedPath}`);
@@ -64,6 +61,45 @@ async function loadSecrets(secretsPath) {
64
61
  return secrets;
65
62
  }
66
63
 
64
+ /**
65
+ * Resolves secrets file path (same logic as loadSecrets)
66
+ * Also checks common locations if path is not provided
67
+ * @function resolveSecretsPath
68
+ * @param {string} [secretsPath] - Path to secrets file (optional)
69
+ * @returns {string} Resolved secrets file path
70
+ */
71
+ function resolveSecretsPath(secretsPath) {
72
+ let resolvedPath = secretsPath;
73
+
74
+ if (!resolvedPath) {
75
+ // Check common locations for secrets.local.yaml
76
+ const commonLocations = [
77
+ path.join(process.cwd(), '..', 'aifabrix-setup', 'secrets.local.yaml'),
78
+ path.join(process.cwd(), '..', '..', 'aifabrix-setup', 'secrets.local.yaml'),
79
+ path.join(process.cwd(), 'secrets.local.yaml'),
80
+ path.join(process.cwd(), '..', 'secrets.local.yaml'),
81
+ path.join(os.homedir(), '.aifabrix', 'secrets.yaml')
82
+ ];
83
+
84
+ // Find first existing file
85
+ for (const location of commonLocations) {
86
+ if (fs.existsSync(location)) {
87
+ resolvedPath = location;
88
+ break;
89
+ }
90
+ }
91
+
92
+ // If none found, use default location
93
+ if (!resolvedPath) {
94
+ resolvedPath = path.join(os.homedir(), '.aifabrix', 'secrets.yaml');
95
+ }
96
+ } else if (secretsPath.startsWith('..')) {
97
+ resolvedPath = path.resolve(process.cwd(), secretsPath);
98
+ }
99
+
100
+ return resolvedPath;
101
+ }
102
+
67
103
  /**
68
104
  * Resolves kv:// references in environment template
69
105
  * Replaces kv://keyName with actual values from secrets
@@ -73,6 +109,7 @@ async function loadSecrets(secretsPath) {
73
109
  * @param {string} envTemplate - Environment template content
74
110
  * @param {Object} secrets - Secrets object from loadSecrets()
75
111
  * @param {string} [environment='local'] - Environment context (docker/local)
112
+ * @param {string} [secretsFilePath] - Path to secrets file (for error messages)
76
113
  * @returns {Promise<string>} Resolved environment content
77
114
  * @throws {Error} If kv:// reference cannot be resolved
78
115
  *
@@ -80,7 +117,7 @@ async function loadSecrets(secretsPath) {
80
117
  * const resolved = await resolveKvReferences(template, secrets, 'local');
81
118
  * // Returns: 'DATABASE_URL=postgresql://user:pass@localhost:5432/db'
82
119
  */
83
- async function resolveKvReferences(envTemplate, secrets, environment = 'local') {
120
+ async function resolveKvReferences(envTemplate, secrets, environment = 'local', secretsFilePath = null) {
84
121
  const envConfig = loadEnvConfig();
85
122
  const envVars = envConfig.environments[environment] || envConfig.environments.local;
86
123
 
@@ -101,7 +138,8 @@ async function resolveKvReferences(envTemplate, secrets, environment = 'local')
101
138
  }
102
139
 
103
140
  if (missingSecrets.length > 0) {
104
- throw new Error(`Missing secrets: ${missingSecrets.join(', ')}`);
141
+ const fileInfo = secretsFilePath ? `\n\nSecrets file location: ${secretsFilePath}` : '';
142
+ throw new Error(`Missing secrets: ${missingSecrets.join(', ')}${fileInfo}`);
105
143
  }
106
144
 
107
145
  // Now replace kv:// references, and handle ${VAR} inside the secret values
@@ -119,146 +157,6 @@ async function resolveKvReferences(envTemplate, secrets, environment = 'local')
119
157
  return resolved;
120
158
  }
121
159
 
122
- /**
123
- * Finds missing secret keys from template
124
- * @function findMissingSecretKeys
125
- * @param {string} envTemplate - Environment template content
126
- * @param {Object} existingSecrets - Existing secrets object
127
- * @returns {string[]} Array of missing secret keys
128
- */
129
- function findMissingSecretKeys(envTemplate, existingSecrets) {
130
- const kvPattern = /kv:\/\/([a-zA-Z0-9-_]+)/g;
131
- const missingKeys = [];
132
- const seenKeys = new Set();
133
-
134
- let match;
135
- while ((match = kvPattern.exec(envTemplate)) !== null) {
136
- const secretKey = match[1];
137
- if (!seenKeys.has(secretKey) && !(secretKey in existingSecrets)) {
138
- missingKeys.push(secretKey);
139
- seenKeys.add(secretKey);
140
- }
141
- }
142
-
143
- return missingKeys;
144
- }
145
-
146
- /**
147
- * Generates secret value based on key name
148
- * @function generateSecretValue
149
- * @param {string} key - Secret key name
150
- * @returns {string} Generated secret value
151
- */
152
- function generateSecretValue(key) {
153
- const keyLower = key.toLowerCase();
154
-
155
- if (keyLower.includes('password')) {
156
- const dbPasswordMatch = key.match(/^databases-([a-z0-9-_]+)-\d+-passwordKeyVault$/i);
157
- if (dbPasswordMatch) {
158
- const appName = dbPasswordMatch[1];
159
- const dbName = appName.replace(/-/g, '_');
160
- return `${dbName}_pass123`;
161
- }
162
- return crypto.randomBytes(32).toString('base64');
163
- }
164
-
165
- if (keyLower.includes('url') || keyLower.includes('uri')) {
166
- const dbUrlMatch = key.match(/^databases-([a-z0-9-_]+)-\d+-urlKeyVault$/i);
167
- if (dbUrlMatch) {
168
- const appName = dbUrlMatch[1];
169
- const dbName = appName.replace(/-/g, '_');
170
- return `postgresql://${dbName}_user:${dbName}_pass123@\${DB_HOST}:5432/${dbName}`;
171
- }
172
- return '';
173
- }
174
-
175
- if (keyLower.includes('key') || keyLower.includes('secret') || keyLower.includes('token')) {
176
- return crypto.randomBytes(32).toString('base64');
177
- }
178
-
179
- return '';
180
- }
181
-
182
- /**
183
- * Loads existing secrets from file
184
- * @function loadExistingSecrets
185
- * @param {string} resolvedPath - Path to secrets file
186
- * @returns {Object} Existing secrets object
187
- */
188
- function loadExistingSecrets(resolvedPath) {
189
- if (!fs.existsSync(resolvedPath)) {
190
- return {};
191
- }
192
-
193
- try {
194
- const content = fs.readFileSync(resolvedPath, 'utf8');
195
- const secrets = yaml.load(content) || {};
196
- return typeof secrets === 'object' ? secrets : {};
197
- } catch (error) {
198
- logger.warn(`Warning: Could not read existing secrets file: ${error.message}`);
199
- return {};
200
- }
201
- }
202
-
203
- /**
204
- * Saves secrets file
205
- * @function saveSecretsFile
206
- * @param {string} resolvedPath - Path to secrets file
207
- * @param {Object} secrets - Secrets object to save
208
- * @throws {Error} If save fails
209
- */
210
- function saveSecretsFile(resolvedPath, secrets) {
211
- const dir = path.dirname(resolvedPath);
212
- if (!fs.existsSync(dir)) {
213
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
214
- }
215
-
216
- const yamlContent = yaml.dump(secrets, {
217
- indent: 2,
218
- lineWidth: 120,
219
- noRefs: true,
220
- sortKeys: false
221
- });
222
-
223
- fs.writeFileSync(resolvedPath, yamlContent, { mode: 0o600 });
224
- }
225
-
226
- /**
227
- * Generates missing secret keys in secrets file
228
- * Scans env.template for kv:// references and adds missing keys with secure defaults
229
- *
230
- * @async
231
- * @function generateMissingSecrets
232
- * @param {string} envTemplate - Environment template content
233
- * @param {string} secretsPath - Path to secrets file
234
- * @returns {Promise<string[]>} Array of newly generated secret keys
235
- * @throws {Error} If generation fails
236
- *
237
- * @example
238
- * const newKeys = await generateMissingSecrets(template, '~/.aifabrix/secrets.yaml');
239
- * // Returns: ['new-secret-key', 'another-secret']
240
- */
241
- async function generateMissingSecrets(envTemplate, secretsPath) {
242
- const resolvedPath = secretsPath || path.join(os.homedir(), '.aifabrix', 'secrets.yaml');
243
- const existingSecrets = loadExistingSecrets(resolvedPath);
244
- const missingKeys = findMissingSecretKeys(envTemplate, existingSecrets);
245
-
246
- if (missingKeys.length === 0) {
247
- return [];
248
- }
249
-
250
- const newSecrets = {};
251
- for (const key of missingKeys) {
252
- newSecrets[key] = generateSecretValue(key);
253
- }
254
-
255
- const updatedSecrets = { ...existingSecrets, ...newSecrets };
256
- saveSecretsFile(resolvedPath, updatedSecrets);
257
-
258
- logger.log(`✓ Generated ${missingKeys.length} missing secret key(s): ${missingKeys.join(', ')}`);
259
- return missingKeys;
260
- }
261
-
262
160
  /**
263
161
  * Loads environment template from file
264
162
  * @function loadEnvTemplate
@@ -335,13 +233,15 @@ async function generateEnvFile(appName, secretsPath, environment = 'local', forc
335
233
 
336
234
  const template = loadEnvTemplate(templatePath);
337
235
 
236
+ // Resolve secrets path to show in error messages
237
+ const resolvedSecretsPath = resolveSecretsPath(secretsPath);
238
+
338
239
  if (force) {
339
- const resolvedSecretsPath = secretsPath || path.join(os.homedir(), '.aifabrix', 'secrets.yaml');
340
240
  await generateMissingSecrets(template, resolvedSecretsPath);
341
241
  }
342
242
 
343
243
  const secrets = await loadSecrets(secretsPath);
344
- const resolved = await resolveKvReferences(template, secrets, environment);
244
+ const resolved = await resolveKvReferences(template, secrets, environment, resolvedSecretsPath);
345
245
 
346
246
  fs.writeFileSync(envPath, resolved, { mode: 0o600 });
347
247
  processEnvVariables(envPath, variablesPath);
@@ -434,48 +334,6 @@ function validateSecrets(envTemplate, secrets) {
434
334
  };
435
335
  }
436
336
 
437
- /**
438
- * Creates default secrets file if it doesn't exist
439
- * Generates template with common secrets for local development
440
- *
441
- * @async
442
- * @function createDefaultSecrets
443
- * @param {string} secretsPath - Path where to create secrets file
444
- * @returns {Promise<void>} Resolves when file is created
445
- * @throws {Error} If file creation fails
446
- *
447
- * @example
448
- * await createDefaultSecrets('~/.aifabrix/secrets.yaml');
449
- * // Default secrets file is created
450
- */
451
- async function createDefaultSecrets(secretsPath) {
452
- const resolvedPath = secretsPath.startsWith('~')
453
- ? path.join(os.homedir(), secretsPath.slice(1))
454
- : secretsPath;
455
-
456
- const dir = path.dirname(resolvedPath);
457
- if (!fs.existsSync(dir)) {
458
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
459
- }
460
-
461
- const defaultSecrets = `# Local Development Secrets
462
- # Production uses Azure KeyVault
463
-
464
- # Database Secrets
465
- postgres-passwordKeyVault: "admin123"
466
-
467
- # Redis Secrets
468
- redis-passwordKeyVault: ""
469
- redis-urlKeyVault: "redis://\${REDIS_HOST}:6379"
470
-
471
- # Keycloak Secrets
472
- keycloak-admin-passwordKeyVault: "admin123"
473
- keycloak-auth-server-urlKeyVault: "http://\${KEYCLOAK_HOST}:8082"
474
- `;
475
-
476
- fs.writeFileSync(resolvedPath, defaultSecrets, { mode: 0o600 });
477
- }
478
-
479
337
  module.exports = {
480
338
  loadSecrets,
481
339
  resolveKvReferences,
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Docker Compose Generation Utilities
3
+ *
4
+ * This module handles Docker Compose configuration generation for application running.
5
+ * Separated from app-run.js to maintain file size limits.
6
+ *
7
+ * @fileoverview Docker Compose generation utilities
8
+ * @author AI Fabrix Team
9
+ * @version 2.0.0
10
+ */
11
+
12
+ const fsSync = require('fs');
13
+ const path = require('path');
14
+ const handlebars = require('handlebars');
15
+
16
+ /**
17
+ * Loads and compiles Docker Compose template
18
+ * @param {string} language - Language type
19
+ * @returns {Function} Compiled Handlebars template
20
+ * @throws {Error} If template not found
21
+ */
22
+ function loadDockerComposeTemplate(language) {
23
+ const templatePath = path.join(__dirname, '..', '..', 'templates', language, 'docker-compose.hbs');
24
+ if (!fsSync.existsSync(templatePath)) {
25
+ throw new Error(`Docker Compose template not found for language: ${language}`);
26
+ }
27
+
28
+ const templateContent = fsSync.readFileSync(templatePath, 'utf8');
29
+ return handlebars.compile(templateContent);
30
+ }
31
+
32
+ /**
33
+ * Extracts image name from configuration (same logic as build.js)
34
+ * @param {Object} config - Application configuration
35
+ * @param {string} appName - Application name (fallback)
36
+ * @returns {string} Image name
37
+ */
38
+ function getImageName(config, appName) {
39
+ if (typeof config.image === 'string') {
40
+ return config.image.split(':')[0];
41
+ } else if (config.image?.name) {
42
+ return config.image.name;
43
+ } else if (config.app?.key) {
44
+ return config.app.key;
45
+ }
46
+ return appName;
47
+ }
48
+
49
+ /**
50
+ * Builds app configuration section
51
+ * @param {string} appName - Application name
52
+ * @param {Object} config - Application configuration
53
+ * @returns {Object} App configuration
54
+ */
55
+ function buildAppConfig(appName, config) {
56
+ return {
57
+ key: appName,
58
+ name: config.displayName || appName
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Builds image configuration section
64
+ * @param {Object} config - Application configuration
65
+ * @param {string} appName - Application name
66
+ * @returns {Object} Image configuration
67
+ */
68
+ function buildImageConfig(config, appName) {
69
+ const imageName = getImageName(config, appName);
70
+ const imageTag = config.image?.tag || 'latest';
71
+ return {
72
+ name: imageName,
73
+ tag: imageTag
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Builds health check configuration section
79
+ * @param {Object} config - Application configuration
80
+ * @returns {Object} Health check configuration
81
+ */
82
+ function buildHealthCheckConfig(config) {
83
+ return {
84
+ path: config.healthCheck?.path || '/health',
85
+ interval: config.healthCheck?.interval || 30
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Builds requires configuration section
91
+ * @param {Object} config - Application configuration
92
+ * @returns {Object} Requires configuration
93
+ */
94
+ function buildRequiresConfig(config) {
95
+ return {
96
+ requiresDatabase: config.requires?.database || config.services?.database || false,
97
+ requiresStorage: config.requires?.storage || config.services?.storage || false,
98
+ requiresRedis: config.requires?.redis || config.services?.redis || false
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Builds service configuration for template data
104
+ * @param {string} appName - Application name
105
+ * @param {Object} config - Application configuration
106
+ * @param {number} port - Application port
107
+ * @returns {Object} Service configuration
108
+ */
109
+ function buildServiceConfig(appName, config, port) {
110
+ const containerPort = config.build?.containerPort || config.port || 3000;
111
+
112
+ return {
113
+ app: buildAppConfig(appName, config),
114
+ image: buildImageConfig(config, appName),
115
+ port: containerPort,
116
+ build: {
117
+ localPort: port
118
+ },
119
+ healthCheck: buildHealthCheckConfig(config),
120
+ ...buildRequiresConfig(config)
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Builds volumes configuration for template data
126
+ * @param {string} appName - Application name
127
+ * @returns {Object} Volumes configuration
128
+ */
129
+ function buildVolumesConfig(appName) {
130
+ // Use forward slashes for Docker paths (works on both Windows and Unix)
131
+ const volumePath = path.join(process.cwd(), 'data', appName);
132
+ return {
133
+ mountVolume: volumePath.replace(/\\/g, '/')
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Builds networks configuration for template data
139
+ * @param {Object} config - Application configuration
140
+ * @returns {Object} Networks configuration
141
+ */
142
+ function buildNetworksConfig(config) {
143
+ // Get databases from requires.databases or top-level databases
144
+ const databases = config.requires?.databases || config.databases || [];
145
+ return {
146
+ databases: databases
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Generates Docker Compose configuration from template
152
+ * @param {string} appName - Application name
153
+ * @param {Object} config - Application configuration
154
+ * @param {Object} options - Run options
155
+ * @returns {Promise<string>} Generated compose content
156
+ */
157
+ async function generateDockerCompose(appName, config, options) {
158
+ const language = config.build?.language || config.language || 'typescript';
159
+ const template = loadDockerComposeTemplate(language);
160
+
161
+ const port = options.port || config.build?.localPort || config.port || 3000;
162
+
163
+ const serviceConfig = buildServiceConfig(appName, config, port);
164
+ const volumesConfig = buildVolumesConfig(appName);
165
+ const networksConfig = buildNetworksConfig(config);
166
+
167
+ // Get absolute path to .env file for docker-compose
168
+ const envFilePath = path.join(process.cwd(), 'builder', appName, '.env');
169
+ const envFileAbsolutePath = envFilePath.replace(/\\/g, '/'); // Use forward slashes for Docker
170
+
171
+ const templateData = {
172
+ ...serviceConfig,
173
+ ...volumesConfig,
174
+ ...networksConfig,
175
+ envFile: envFileAbsolutePath
176
+ };
177
+
178
+ return template(templateData);
179
+ }
180
+
181
+ module.exports = {
182
+ generateDockerCompose,
183
+ getImageName
184
+ };
185
+
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Docker Build Utilities
3
+ *
4
+ * This module handles Docker image building with progress indicators.
5
+ * Separated from build.js to maintain file size limits.
6
+ *
7
+ * @fileoverview Docker build execution utilities
8
+ * @author AI Fabrix Team
9
+ * @version 2.0.0
10
+ */
11
+
12
+ const { spawn } = require('child_process');
13
+ const ora = require('ora');
14
+
15
+ /**
16
+ * Checks if error indicates Docker is not running or not installed
17
+ * @param {string} errorMessage - Error message to check
18
+ * @returns {boolean} True if Docker is not available
19
+ */
20
+ function isDockerNotAvailableError(errorMessage) {
21
+ return errorMessage.includes('docker: command not found') ||
22
+ errorMessage.includes('Cannot connect to the Docker daemon') ||
23
+ errorMessage.includes('Is the docker daemon running') ||
24
+ errorMessage.includes('Cannot connect to Docker');
25
+ }
26
+
27
+ /**
28
+ * Parses Docker build output to extract progress information
29
+ * @param {string} line - Single line of Docker build output
30
+ * @returns {string|null} Progress message or null if no progress info
31
+ */
32
+ function parseDockerBuildProgress(line) {
33
+ // Match step progress: "Step 1/10 : FROM node:20-alpine"
34
+ const stepMatch = line.match(/^Step\s+(\d+)\/(\d+)\s*:/i);
35
+ if (stepMatch) {
36
+ return `Step ${stepMatch[1]}/${stepMatch[2]}`;
37
+ }
38
+
39
+ // Match layer pulling: "Pulling from library/node"
40
+ const pullingMatch = line.match(/^Pulling\s+(.+)$/i);
41
+ if (pullingMatch) {
42
+ return `Pulling ${pullingMatch[1]}`;
43
+ }
44
+
45
+ // Match layer extracting: "Extracting [====> ]"
46
+ const extractingMatch = line.match(/^Extracting/i);
47
+ if (extractingMatch) {
48
+ return 'Extracting layers';
49
+ }
50
+
51
+ // Match build progress: " => [internal] load build context"
52
+ const buildMatch = line.match(/^=>\s*\[(.*?)\]\s*(.+)$/i);
53
+ if (buildMatch) {
54
+ return buildMatch[2].substring(0, 50);
55
+ }
56
+
57
+ // Match progress bars: "[====> ] 10.5MB/50MB"
58
+ const progressMatch = line.match(/\[.*?\]\s+([\d.]+(MB|KB|GB))\/([\d.]+(MB|KB|GB))/i);
59
+ if (progressMatch) {
60
+ return `${progressMatch[1]}/${progressMatch[3]}`;
61
+ }
62
+
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * Executes Docker build command with progress indicator
68
+ * @param {string} imageName - Image name to build
69
+ * @param {string} dockerfilePath - Path to Dockerfile
70
+ * @param {string} contextPath - Build context path
71
+ * @param {string} tag - Image tag
72
+ * @returns {Promise<void>} Resolves when build completes
73
+ * @throws {Error} If build fails
74
+ */
75
+ async function executeDockerBuild(imageName, dockerfilePath, contextPath, tag) {
76
+ const spinner = ora({
77
+ text: 'Starting Docker build...',
78
+ spinner: 'dots'
79
+ }).start();
80
+
81
+ return new Promise((resolve, reject) => {
82
+ // Use spawn for streaming output
83
+ const dockerProcess = spawn('docker', [
84
+ 'build',
85
+ '-t', `${imageName}:${tag}`,
86
+ '-f', dockerfilePath,
87
+ contextPath
88
+ ], {
89
+ shell: process.platform === 'win32'
90
+ });
91
+
92
+ let stdoutBuffer = '';
93
+ let stderrBuffer = '';
94
+ let lastProgressUpdate = Date.now();
95
+
96
+ dockerProcess.stdout.on('data', (data) => {
97
+ const output = data.toString();
98
+ stdoutBuffer += output;
99
+
100
+ // Parse progress from output lines
101
+ const lines = output.split('\n');
102
+ for (const line of lines) {
103
+ const progress = parseDockerBuildProgress(line.trim());
104
+ if (progress) {
105
+ // Update spinner text with progress (throttle updates)
106
+ const now = Date.now();
107
+ if (now - lastProgressUpdate > 200) {
108
+ spinner.text = `Building image... ${progress}`;
109
+ lastProgressUpdate = now;
110
+ }
111
+ }
112
+ }
113
+ });
114
+
115
+ dockerProcess.stderr.on('data', (data) => {
116
+ const output = data.toString();
117
+ stderrBuffer += output;
118
+
119
+ // Check for warnings vs errors
120
+ if (!output.toLowerCase().includes('warning')) {
121
+ // Parse progress from stderr too (Docker outputs progress to stderr)
122
+ const lines = output.split('\n');
123
+ for (const line of lines) {
124
+ const progress = parseDockerBuildProgress(line.trim());
125
+ if (progress) {
126
+ const now = Date.now();
127
+ if (now - lastProgressUpdate > 200) {
128
+ spinner.text = `Building image... ${progress}`;
129
+ lastProgressUpdate = now;
130
+ }
131
+ }
132
+ }
133
+ }
134
+ });
135
+
136
+ dockerProcess.on('close', (code) => {
137
+ if (code === 0) {
138
+ spinner.succeed(`Image built: ${imageName}:${tag}`);
139
+ resolve();
140
+ } else {
141
+ spinner.fail('Build failed');
142
+
143
+ const errorMessage = stderrBuffer || stdoutBuffer || 'Docker build failed';
144
+
145
+ if (isDockerNotAvailableError(errorMessage)) {
146
+ reject(new Error('Docker is not running or not installed. Please start Docker Desktop and try again.'));
147
+ } else {
148
+ // Show last few lines of error output
149
+ const errorLines = errorMessage.split('\n').filter(line => line.trim());
150
+ const lastError = errorLines.slice(-5).join('\n');
151
+ reject(new Error(`Docker build failed: ${lastError}`));
152
+ }
153
+ }
154
+ });
155
+
156
+ dockerProcess.on('error', (error) => {
157
+ spinner.fail('Build failed');
158
+ const errorMessage = error.message || String(error);
159
+
160
+ if (isDockerNotAvailableError(errorMessage)) {
161
+ reject(new Error('Docker is not running or not installed. Please start Docker Desktop and try again.'));
162
+ } else {
163
+ reject(new Error(`Docker build failed: ${errorMessage}`));
164
+ }
165
+ });
166
+ });
167
+ }
168
+
169
+ module.exports = {
170
+ executeDockerBuild,
171
+ isDockerNotAvailableError
172
+ };
173
+