@aifabrix/builder 2.2.0 → 2.3.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/build.js CHANGED
@@ -28,6 +28,52 @@ const buildCopy = require('./utils/build-copy');
28
28
 
29
29
  const execAsync = promisify(exec);
30
30
 
31
+ /**
32
+ * Copies application template files to dev directory
33
+ * Used when apps directory doesn't exist to ensure build can proceed
34
+ * @async
35
+ * @param {string} templatePath - Path to template directory
36
+ * @param {string} devDir - Target dev directory
37
+ * @param {string} _language - Language (typescript/python) - currently unused but kept for future use
38
+ * @throws {Error} If copying fails
39
+ */
40
+ async function copyTemplateFilesToDevDir(templatePath, devDir, _language) {
41
+ if (!fsSync.existsSync(templatePath)) {
42
+ throw new Error(`Template path not found: ${templatePath}`);
43
+ }
44
+
45
+ const entries = await fs.readdir(templatePath);
46
+
47
+ // Copy only application files, skip Dockerfile and docker-compose templates
48
+ const appFiles = entries.filter(entry => {
49
+ const lowerEntry = entry.toLowerCase();
50
+ // Include .gitignore, exclude .hbs files and docker-related files
51
+ if (entry === '.gitignore') {
52
+ return true;
53
+ }
54
+ if (lowerEntry.endsWith('.hbs')) {
55
+ return false;
56
+ }
57
+ if (lowerEntry.startsWith('dockerfile') || lowerEntry.includes('docker-compose')) {
58
+ return false;
59
+ }
60
+ if (entry.startsWith('.') && entry !== '.gitignore') {
61
+ return false;
62
+ }
63
+ return true;
64
+ });
65
+
66
+ for (const entry of appFiles) {
67
+ const sourcePath = path.join(templatePath, entry);
68
+ const targetPath = path.join(devDir, entry);
69
+
70
+ const entryStats = await fs.stat(sourcePath);
71
+ if (entryStats.isFile()) {
72
+ await fs.copyFile(sourcePath, targetPath);
73
+ }
74
+ }
75
+ }
76
+
31
77
  /**
32
78
  * Loads variables.yaml configuration for an application
33
79
  * @param {string} appName - Application name
@@ -91,7 +137,6 @@ function detectLanguage(appPath) {
91
137
  const packageJsonPath = path.join(appPath, 'package.json');
92
138
  const requirementsPath = path.join(appPath, 'requirements.txt');
93
139
  const pyprojectPath = path.join(appPath, 'pyproject.toml');
94
- const dockerfilePath = path.join(appPath, 'Dockerfile');
95
140
 
96
141
  // Check for package.json (TypeScript/Node.js)
97
142
  if (fsSync.existsSync(packageJsonPath)) {
@@ -103,11 +148,6 @@ function detectLanguage(appPath) {
103
148
  return 'python';
104
149
  }
105
150
 
106
- // Check for custom Dockerfile
107
- if (fsSync.existsSync(dockerfilePath)) {
108
- throw new Error('Custom Dockerfile found. Use --force-template to regenerate from template.');
109
- }
110
-
111
151
  // Default to typescript if no indicators found
112
152
  return 'typescript';
113
153
  }
@@ -315,11 +355,43 @@ async function buildApp(appName, options = {}) {
315
355
  const devDir = await buildCopy.copyBuilderToDevDirectory(appName, developerId);
316
356
  logger.log(chalk.green(`✓ Files copied to: ${devDir}`));
317
357
 
358
+ // 2a. Check if application source files exist, if not copy from templates
359
+ const appsPath = path.join(process.cwd(), 'apps', appName);
360
+ if (fsSync.existsSync(appsPath)) {
361
+ // Copy app source files from apps directory
362
+ await buildCopy.copyAppSourceFiles(appsPath, devDir);
363
+ logger.log(chalk.green(`✓ Copied application source files from apps/${appName}`));
364
+ } else {
365
+ // No apps directory - check if we need to copy template files
366
+ const language = options.language || buildConfig.language || detectLanguage(devDir);
367
+ const packageJsonPath = path.join(devDir, 'package.json');
368
+ const requirementsPath = path.join(devDir, 'requirements.txt');
369
+
370
+ if (language === 'typescript' && !fsSync.existsSync(packageJsonPath)) {
371
+ // Copy TypeScript template files
372
+ const templatePath = path.join(__dirname, '..', 'templates', 'typescript');
373
+ await copyTemplateFilesToDevDir(templatePath, devDir, language);
374
+ logger.log(chalk.green(`✓ Generated application files from ${language} template`));
375
+ } else if (language === 'python' && !fsSync.existsSync(requirementsPath)) {
376
+ // Copy Python template files
377
+ const templatePath = path.join(__dirname, '..', 'templates', 'python');
378
+ await copyTemplateFilesToDevDir(templatePath, devDir, language);
379
+ logger.log(chalk.green(`✓ Generated application files from ${language} template`));
380
+ }
381
+ }
382
+
318
383
  // 3. Prepare build context (use dev-specific directory)
319
384
  // If buildConfig.context is relative, resolve it relative to devDir
320
385
  // If buildConfig.context is '../..' (apps flag), keep it as is (it's relative to devDir)
321
386
  let contextPath;
322
- if (buildConfig.context && buildConfig.context !== '../..') {
387
+
388
+ // Check if context is using old format (../appName) - these are incompatible with dev directory structure
389
+ if (buildConfig.context && buildConfig.context.startsWith('../') && buildConfig.context !== '../..') {
390
+ // Old format detected - always use devDir instead
391
+ logger.log(chalk.yellow(`⚠️ Warning: Build context uses old format: ${buildConfig.context}`));
392
+ logger.log(chalk.yellow(` Using dev directory instead: ${devDir}`));
393
+ contextPath = devDir;
394
+ } else if (buildConfig.context && buildConfig.context !== '../..') {
323
395
  // Resolve relative context path from dev directory
324
396
  contextPath = path.resolve(devDir, buildConfig.context);
325
397
  } else if (buildConfig.context === '../..') {
@@ -333,6 +405,22 @@ async function buildApp(appName, options = {}) {
333
405
  contextPath = devDir;
334
406
  }
335
407
 
408
+ // Ensure context path is absolute and normalized
409
+ contextPath = path.resolve(contextPath);
410
+
411
+ // Validate that context path exists (skip in test environments)
412
+ const isTestEnv = process.env.NODE_ENV === 'test' ||
413
+ process.env.JEST_WORKER_ID !== undefined ||
414
+ typeof jest !== 'undefined';
415
+
416
+ if (!isTestEnv && !fsSync.existsSync(contextPath)) {
417
+ throw new Error(
418
+ `Build context path does not exist: ${contextPath}\n` +
419
+ `Expected dev directory: ${devDir}\n` +
420
+ 'Please ensure files were copied correctly or update the context in variables.yaml.'
421
+ );
422
+ }
423
+
336
424
  // 4. Check if Dockerfile exists in dev directory
337
425
  const appDockerfilePath = path.join(devDir, 'Dockerfile');
338
426
  const hasExistingDockerfile = fsSync.existsSync(appDockerfilePath) && !options.forceTemplate;
@@ -362,6 +450,11 @@ async function buildApp(appName, options = {}) {
362
450
 
363
451
  // 6. Build Docker image
364
452
  const tag = options.tag || 'latest';
453
+
454
+ // Log paths for debugging
455
+ logger.log(chalk.blue(`Using Dockerfile: ${dockerfilePath}`));
456
+ logger.log(chalk.blue(`Using build context: ${contextPath}`));
457
+
365
458
  await executeBuild(imageName, dockerfilePath, contextPath, tag, options);
366
459
 
367
460
  // 7. Post-build tasks
package/lib/cli.js CHANGED
@@ -21,6 +21,7 @@ const chalk = require('chalk');
21
21
  const logger = require('./utils/logger');
22
22
  const { validateCommand, handleCommandError } = require('./utils/cli-utils');
23
23
  const { handleLogin } = require('./commands/login');
24
+ const { handleSecure } = require('./commands/secure');
24
25
 
25
26
  /**
26
27
  * Sets up all CLI commands on the Commander program instance
@@ -373,6 +374,19 @@ function setupCommands(program) {
373
374
  process.exit(1);
374
375
  }
375
376
  });
377
+
378
+ // Security command
379
+ program.command('secure')
380
+ .description('Encrypt secrets in secrets.local.yaml files for ISO 27001 compliance')
381
+ .option('--secrets-encryption <key>', 'Encryption key (32 bytes, hex or base64)')
382
+ .action(async(options) => {
383
+ try {
384
+ await handleSecure(options);
385
+ } catch (error) {
386
+ handleCommandError(error, 'secure');
387
+ process.exit(1);
388
+ }
389
+ });
376
390
  }
377
391
 
378
392
  module.exports = {
@@ -0,0 +1,260 @@
1
+ /**
2
+ * AI Fabrix Builder - Secure Command
3
+ *
4
+ * Handles encryption of secrets in secrets.local.yaml files
5
+ * Sets encryption key in config.yaml and encrypts all secret values
6
+ *
7
+ * @fileoverview Secure command implementation for AI Fabrix Builder
8
+ * @author AI Fabrix Team
9
+ * @version 2.0.0
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const os = require('os');
15
+ const yaml = require('js-yaml');
16
+ const inquirer = require('inquirer');
17
+ const chalk = require('chalk');
18
+ const logger = require('../utils/logger');
19
+ const { setSecretsEncryptionKey, getSecretsEncryptionKey } = require('../config');
20
+ const { encryptSecret, isEncrypted, validateEncryptionKey } = require('../utils/secrets-encryption');
21
+
22
+ /**
23
+ * Finds all secrets.local.yaml files to encrypt
24
+ * Includes user secrets file and build secrets from all apps
25
+ *
26
+ * @async
27
+ * @function findSecretsFiles
28
+ * @returns {Promise<Array<{path: string, type: string}>>} Array of secrets file paths
29
+ */
30
+ async function findSecretsFiles() {
31
+ const files = [];
32
+
33
+ // User's secrets file
34
+ const userSecretsPath = path.join(os.homedir(), '.aifabrix', 'secrets.local.yaml');
35
+ if (fs.existsSync(userSecretsPath)) {
36
+ files.push({ path: userSecretsPath, type: 'user' });
37
+ }
38
+
39
+ // Find all apps and check for build.secrets
40
+ // Scan builder directory for apps
41
+ try {
42
+ const builderDir = path.join(process.cwd(), 'builder');
43
+ if (fs.existsSync(builderDir)) {
44
+ const entries = fs.readdirSync(builderDir, { withFileTypes: true });
45
+ for (const entry of entries) {
46
+ if (entry.isDirectory()) {
47
+ const appName = entry.name;
48
+ const variablesPath = path.join(builderDir, appName, 'variables.yaml');
49
+ if (fs.existsSync(variablesPath)) {
50
+ try {
51
+ const variablesContent = fs.readFileSync(variablesPath, 'utf8');
52
+ const variables = yaml.load(variablesContent);
53
+
54
+ if (variables?.build?.secrets) {
55
+ const buildSecretsPath = path.resolve(
56
+ path.dirname(variablesPath),
57
+ variables.build.secrets
58
+ );
59
+ if (fs.existsSync(buildSecretsPath)) {
60
+ files.push({ path: buildSecretsPath, type: `app:${appName}` });
61
+ }
62
+ }
63
+ } catch (error) {
64
+ // Ignore errors, continue
65
+ }
66
+ }
67
+ }
68
+ }
69
+ }
70
+ } catch (error) {
71
+ // Ignore errors, continue
72
+ }
73
+
74
+ // Check config.yaml for general secrets-path
75
+ try {
76
+ const { getSecretsPath } = require('../config');
77
+ const generalSecretsPath = await getSecretsPath();
78
+ if (generalSecretsPath) {
79
+ const resolvedPath = path.isAbsolute(generalSecretsPath)
80
+ ? generalSecretsPath
81
+ : path.resolve(process.cwd(), generalSecretsPath);
82
+ if (fs.existsSync(resolvedPath) && !files.some(f => f.path === resolvedPath)) {
83
+ files.push({ path: resolvedPath, type: 'general' });
84
+ }
85
+ }
86
+ } catch (error) {
87
+ // Ignore errors, continue
88
+ }
89
+
90
+ return files;
91
+ }
92
+
93
+ /**
94
+ * Encrypts all non-encrypted values in a secrets file
95
+ * Preserves YAML structure and comments
96
+ *
97
+ * @async
98
+ * @function encryptSecretsFile
99
+ * @param {string} filePath - Path to secrets file
100
+ * @param {string} encryptionKey - Encryption key
101
+ * @returns {Promise<{encrypted: number, total: number}>} Count of encrypted values
102
+ */
103
+ async function encryptSecretsFile(filePath, encryptionKey) {
104
+ const content = fs.readFileSync(filePath, 'utf8');
105
+ const secrets = yaml.load(content);
106
+
107
+ if (!secrets || typeof secrets !== 'object') {
108
+ throw new Error(`Invalid secrets file format: ${filePath}`);
109
+ }
110
+
111
+ let encryptedCount = 0;
112
+ let totalCount = 0;
113
+ const updatedSecrets = {};
114
+
115
+ for (const [key, value] of Object.entries(secrets)) {
116
+ totalCount++;
117
+ if (typeof value === 'string' && value.trim() !== '') {
118
+ if (isEncrypted(value)) {
119
+ // Already encrypted, keep as-is
120
+ updatedSecrets[key] = value;
121
+ } else {
122
+ // Encrypt the value
123
+ updatedSecrets[key] = encryptSecret(value, encryptionKey);
124
+ encryptedCount++;
125
+ }
126
+ } else {
127
+ // Non-string or empty value, keep as-is
128
+ updatedSecrets[key] = value;
129
+ }
130
+ }
131
+
132
+ // Write back to file with same formatting
133
+ // Use yaml.dump with appropriate options to preserve structure
134
+ const yamlContent = yaml.dump(updatedSecrets, {
135
+ indent: 2,
136
+ lineWidth: -1,
137
+ noRefs: true,
138
+ sortKeys: false
139
+ });
140
+
141
+ fs.writeFileSync(filePath, yamlContent, { mode: 0o600 });
142
+
143
+ return { encrypted: encryptedCount, total: totalCount };
144
+ }
145
+
146
+ /**
147
+ * Prompt for encryption key if not provided
148
+ *
149
+ * @async
150
+ * @function promptForEncryptionKey
151
+ * @returns {Promise<string>} Encryption key
152
+ */
153
+ async function promptForEncryptionKey() {
154
+ const answer = await inquirer.prompt([{
155
+ type: 'password',
156
+ name: 'key',
157
+ message: 'Enter encryption key (32 bytes, hex or base64):',
158
+ mask: '*',
159
+ validate: (input) => {
160
+ if (!input || input.trim().length === 0) {
161
+ return 'Encryption key is required';
162
+ }
163
+ try {
164
+ validateEncryptionKey(input.trim());
165
+ return true;
166
+ } catch (error) {
167
+ return error.message;
168
+ }
169
+ }
170
+ }]);
171
+
172
+ return answer.key.trim();
173
+ }
174
+
175
+ /**
176
+ * Handle secure command action
177
+ * Sets encryption key and encrypts all secrets files
178
+ *
179
+ * @async
180
+ * @function handleSecure
181
+ * @param {Object} options - Command options
182
+ * @param {string} [options.secretsEncryption] - Encryption key (optional, will prompt if not provided)
183
+ * @returns {Promise<void>} Resolves when encryption completes
184
+ * @throws {Error} If encryption fails
185
+ */
186
+ async function handleSecure(options) {
187
+ logger.log(chalk.blue('\n🔐 Securing secrets files...\n'));
188
+
189
+ // Get or prompt for encryption key
190
+ let encryptionKey = options.secretsEncryption || options['secrets-encryption'];
191
+ if (!encryptionKey) {
192
+ // Check if key already exists in config
193
+ const existingKey = await getSecretsEncryptionKey();
194
+ if (existingKey) {
195
+ logger.log(chalk.yellow('⚠️ Encryption key already configured in config.yaml'));
196
+ const useExisting = await inquirer.prompt([{
197
+ type: 'confirm',
198
+ name: 'use',
199
+ message: 'Use existing encryption key?',
200
+ default: true
201
+ }]);
202
+ if (useExisting.use) {
203
+ encryptionKey = existingKey;
204
+ } else {
205
+ encryptionKey = await promptForEncryptionKey();
206
+ await setSecretsEncryptionKey(encryptionKey);
207
+ logger.log(chalk.green('✓ Encryption key saved to config.yaml'));
208
+ }
209
+ } else {
210
+ encryptionKey = await promptForEncryptionKey();
211
+ await setSecretsEncryptionKey(encryptionKey);
212
+ logger.log(chalk.green('✓ Encryption key saved to config.yaml'));
213
+ }
214
+ } else {
215
+ // Validate and save the provided key
216
+ validateEncryptionKey(encryptionKey);
217
+ await setSecretsEncryptionKey(encryptionKey);
218
+ logger.log(chalk.green('✓ Encryption key saved to config.yaml'));
219
+ }
220
+
221
+ // Find all secrets files
222
+ const secretsFiles = await findSecretsFiles();
223
+
224
+ if (secretsFiles.length === 0) {
225
+ logger.log(chalk.yellow('⚠️ No secrets files found to encrypt'));
226
+ logger.log(chalk.gray(' Create ~/.aifabrix/secrets.local.yaml or configure build.secrets in variables.yaml'));
227
+ return;
228
+ }
229
+
230
+ logger.log(chalk.gray(`Found ${secretsFiles.length} secrets file(s) to process:\n`));
231
+
232
+ // Encrypt each file
233
+ let totalEncrypted = 0;
234
+ let totalValues = 0;
235
+
236
+ for (const file of secretsFiles) {
237
+ try {
238
+ logger.log(chalk.gray(`Processing: ${file.path} (${file.type})`));
239
+ const result = await encryptSecretsFile(file.path, encryptionKey);
240
+ totalEncrypted += result.encrypted;
241
+ totalValues += result.total;
242
+
243
+ if (result.encrypted > 0) {
244
+ logger.log(chalk.green(` ✓ Encrypted ${result.encrypted} of ${result.total} values`));
245
+ } else {
246
+ logger.log(chalk.gray(` - All values already encrypted (${result.total} total)`));
247
+ }
248
+ } catch (error) {
249
+ logger.log(chalk.red(` ✗ Error: ${error.message}`));
250
+ }
251
+ }
252
+
253
+ logger.log(chalk.green('\n✅ Encryption complete!'));
254
+ logger.log(chalk.gray(` Files processed: ${secretsFiles.length}`));
255
+ logger.log(chalk.gray(` Values encrypted: ${totalEncrypted} of ${totalValues} total`));
256
+ logger.log(chalk.gray(' Encryption key stored in: ~/.aifabrix/config.yaml\n'));
257
+ }
258
+
259
+ module.exports = { handleSecure };
260
+
package/lib/config.js CHANGED
@@ -297,6 +297,60 @@ async function loadDeveloperId() {
297
297
  return cachedDeveloperId;
298
298
  }
299
299
 
300
+ /**
301
+ * Get secrets encryption key from configuration
302
+ * @returns {Promise<string|null>} Encryption key or null if not set
303
+ */
304
+ async function getSecretsEncryptionKey() {
305
+ const config = await getConfig();
306
+ return config['secrets-encryption'] || null;
307
+ }
308
+
309
+ /**
310
+ * Set secrets encryption key in configuration
311
+ * @param {string} key - Encryption key (32 bytes, hex or base64)
312
+ * @returns {Promise<void>}
313
+ * @throws {Error} If key format is invalid
314
+ */
315
+ async function setSecretsEncryptionKey(key) {
316
+ if (!key || typeof key !== 'string') {
317
+ throw new Error('Encryption key is required and must be a string');
318
+ }
319
+
320
+ // Validate key format using encryption utilities
321
+ const { validateEncryptionKey } = require('./utils/secrets-encryption');
322
+ validateEncryptionKey(key);
323
+
324
+ const config = await getConfig();
325
+ config['secrets-encryption'] = key;
326
+ await saveConfig(config);
327
+ }
328
+
329
+ /**
330
+ * Get general secrets path from configuration
331
+ * Used as fallback when build.secrets is not set in variables.yaml
332
+ * @returns {Promise<string|null>} Secrets path or null if not set
333
+ */
334
+ async function getSecretsPath() {
335
+ const config = await getConfig();
336
+ return config['secrets-path'] || null;
337
+ }
338
+
339
+ /**
340
+ * Set general secrets path in configuration
341
+ * @param {string} secretsPath - Path to general secrets file
342
+ * @returns {Promise<void>}
343
+ */
344
+ async function setSecretsPath(secretsPath) {
345
+ if (!secretsPath || typeof secretsPath !== 'string') {
346
+ throw new Error('Secrets path is required and must be a string');
347
+ }
348
+
349
+ const config = await getConfig();
350
+ config['secrets-path'] = secretsPath;
351
+ await saveConfig(config);
352
+ }
353
+
300
354
  // Create exports object
301
355
  const exportsObj = {
302
356
  getConfig,
@@ -312,6 +366,10 @@ const exportsObj = {
312
366
  getClientToken,
313
367
  saveDeviceToken,
314
368
  saveClientToken,
369
+ getSecretsEncryptionKey,
370
+ setSecretsEncryptionKey,
371
+ getSecretsPath,
372
+ setSecretsPath,
315
373
  CONFIG_DIR,
316
374
  CONFIG_FILE
317
375
  };
package/lib/push.js CHANGED
@@ -21,12 +21,35 @@ const execAsync = promisify(exec);
21
21
  * @returns {Promise<boolean>} True if Azure CLI is available
22
22
  */
23
23
  async function checkAzureCLIInstalled() {
24
- try {
25
- await execAsync('az --version');
26
- return true;
27
- } catch (error) {
28
- return false;
24
+ // On Windows, use shell option to ensure proper command resolution
25
+ const options = process.platform === 'win32' ? { shell: true } : {};
26
+
27
+ // Try multiple methods to detect Azure CLI (commands that don't require authentication)
28
+ const commands = process.platform === 'win32'
29
+ ? ['az --version', 'az.cmd --version']
30
+ : ['az --version'];
31
+
32
+ for (const command of commands) {
33
+ try {
34
+ // Use a timeout to avoid hanging if command doesn't exist
35
+ await execAsync(command, { ...options, timeout: 5000 });
36
+ return true;
37
+ } catch (error) {
38
+ // Log the error for debugging (only in development)
39
+ if (process.env.DEBUG) {
40
+ logger.log(chalk.gray(`[DEBUG] Command '${command}' failed: ${error.message}`));
41
+ }
42
+ // Continue to next command if this one fails
43
+ continue;
44
+ }
45
+ }
46
+
47
+ // If all commands failed, Azure CLI is not available
48
+ // Log for debugging if enabled
49
+ if (process.env.DEBUG) {
50
+ logger.log(chalk.gray('[DEBUG] All Azure CLI detection methods failed'));
29
51
  }
52
+ return false;
30
53
  }
31
54
 
32
55
  /**
@@ -103,7 +126,9 @@ function validateRegistryURL(registryUrl) {
103
126
  async function checkACRAuthentication(registry) {
104
127
  try {
105
128
  const registryName = extractRegistryName(registry);
106
- await execAsync(`az acr show --name ${registryName}`);
129
+ // On Windows, use shell option to ensure proper command resolution
130
+ const options = process.platform === 'win32' ? { shell: true } : {};
131
+ await execAsync(`az acr show --name ${registryName}`, options);
107
132
  return true;
108
133
  } catch (error) {
109
134
  return false;
@@ -119,7 +144,9 @@ async function authenticateACR(registry) {
119
144
  try {
120
145
  const registryName = extractRegistryName(registry);
121
146
  logger.log(chalk.blue(`Authenticating with ${registry}...`));
122
- await execAsync(`az acr login --name ${registryName}`);
147
+ // On Windows, use shell option to ensure proper command resolution
148
+ const options = process.platform === 'win32' ? { shell: true } : {};
149
+ await execAsync(`az acr login --name ${registryName}`, options);
123
150
  logger.log(chalk.green(`✓ Authenticated with ${registry}`));
124
151
  } catch (error) {
125
152
  throw new Error(`Failed to authenticate with Azure Container Registry: ${error.message}`);