@aifabrix/builder 2.1.5 → 2.1.7

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.
@@ -16,6 +16,8 @@ const yaml = require('js-yaml');
16
16
  const { getConfig } = require('../config');
17
17
  const { authenticatedApiCall } = require('../utils/api');
18
18
  const logger = require('../utils/logger');
19
+ const { saveLocalSecret, isLocalhost } = require('../utils/local-secrets');
20
+ const { updateEnvTemplate } = require('../utils/env-template');
19
21
 
20
22
  // Import createApp to auto-generate config if missing
21
23
  let createApp;
@@ -312,6 +314,26 @@ function setupAppCommands(program) {
312
314
  registrationData
313
315
  );
314
316
 
317
+ // Save credentials to local secrets if localhost
318
+ if (isLocalhost(config.apiUrl)) {
319
+ const appKey = responseData.application.key;
320
+ const clientIdKey = `${appKey}-client-idKeyVault`;
321
+ const clientSecretKey = `${appKey}-client-secretKeyVault`;
322
+
323
+ try {
324
+ await saveLocalSecret(clientIdKey, responseData.credentials.clientId);
325
+ await saveLocalSecret(clientSecretKey, responseData.credentials.clientSecret);
326
+
327
+ // Update env.template
328
+ await updateEnvTemplate(appKey, clientIdKey, clientSecretKey);
329
+
330
+ logger.log(chalk.green('\n✓ Credentials saved to ~/.aifabrix/secrets.local.yaml'));
331
+ logger.log(chalk.green('✓ env.template updated with MISO_CLIENTID and MISO_CLIENTSECRET\n'));
332
+ } catch (error) {
333
+ logger.warn(chalk.yellow(`⚠️ Could not save credentials locally: ${error.message}`));
334
+ }
335
+ }
336
+
315
337
  // Display results
316
338
  displayRegistrationResults(responseData, config.apiUrl);
317
339
 
@@ -403,6 +425,27 @@ function setupAppCommands(program) {
403
425
  logger.log(chalk.yellow(` Client Secret: ${response.data.credentials.clientSecret}\n`));
404
426
  logger.log(chalk.red('⚠️ Old secret is now invalid. Update GitHub Secrets!\n'));
405
427
 
428
+ // Save credentials to local secrets if localhost
429
+ if (isLocalhost(config.apiUrl)) {
430
+ const appKey = response.data.application?.key || options.app;
431
+ const clientIdKey = `${appKey}-client-idKeyVault`;
432
+ const clientSecretKey = `${appKey}-client-secretKeyVault`;
433
+
434
+ try {
435
+ await saveLocalSecret(clientIdKey, response.data.credentials.clientId);
436
+ await saveLocalSecret(clientSecretKey, response.data.credentials.clientSecret);
437
+
438
+ // Update env.template
439
+ await updateEnvTemplate(appKey, clientIdKey, clientSecretKey);
440
+
441
+ logger.log(chalk.green('✓ Credentials saved to ~/.aifabrix/secrets.local.yaml'));
442
+ logger.log(chalk.green('✓ env.template updated with MISO_CLIENTID and MISO_CLIENTSECRET'));
443
+ logger.log('');
444
+ } catch (error) {
445
+ logger.warn(chalk.yellow(`⚠️ Could not save credentials locally: ${error.message}`));
446
+ }
447
+ }
448
+
406
449
  } catch (error) {
407
450
  logger.error(chalk.red('❌ Rotation failed:'), error.message);
408
451
  process.exit(1);
package/lib/secrets.js CHANGED
@@ -19,6 +19,17 @@ const {
19
19
  generateMissingSecrets,
20
20
  createDefaultSecrets
21
21
  } = require('./utils/secrets-generator');
22
+ const {
23
+ resolveSecretsPath,
24
+ getActualSecretsPath
25
+ } = require('./utils/secrets-path');
26
+ const {
27
+ loadUserSecrets,
28
+ loadBuildSecrets,
29
+ loadDefaultSecrets,
30
+ buildHostnameToServiceMap,
31
+ resolveUrlPort
32
+ } = require('./utils/secrets-utils');
22
33
 
23
34
  /**
24
35
  * Loads environment configuration for docker/local context
@@ -31,73 +42,58 @@ function loadEnvConfig() {
31
42
  }
32
43
 
33
44
  /**
34
- * Loads secrets from the specified file or default location
35
- * Supports both user secrets (~/.aifabrix/secrets.yaml) and project overrides
45
+ * Loads secrets with cascading lookup
46
+ * Supports both user secrets (~/.aifabrix/secrets.local.yaml) and project overrides
47
+ * User's file takes priority, then falls back to build.secrets from variables.yaml
36
48
  *
37
49
  * @async
38
50
  * @function loadSecrets
39
- * @param {string} [secretsPath] - Path to secrets file (optional)
51
+ * @param {string} [secretsPath] - Path to secrets file (optional, for explicit override)
52
+ * @param {string} [appName] - Application name (optional, for variables.yaml lookup)
40
53
  * @returns {Promise<Object>} Loaded secrets object
41
- * @throws {Error} If secrets file cannot be loaded or parsed
54
+ * @throws {Error} If no secrets file found and no fallback available
42
55
  *
43
56
  * @example
44
57
  * const secrets = await loadSecrets('../../secrets.local.yaml');
45
58
  * // Returns: { 'postgres-passwordKeyVault': 'admin123', ... }
46
59
  */
47
- async function loadSecrets(secretsPath) {
48
- const resolvedPath = resolveSecretsPath(secretsPath);
60
+ async function loadSecrets(secretsPath, appName) {
61
+ // If explicit path provided, use it (backward compatibility)
62
+ if (secretsPath) {
63
+ const resolvedPath = resolveSecretsPath(secretsPath);
64
+ if (!fs.existsSync(resolvedPath)) {
65
+ throw new Error(`Secrets file not found: ${resolvedPath}`);
66
+ }
49
67
 
50
- if (!fs.existsSync(resolvedPath)) {
51
- throw new Error(`Secrets file not found: ${resolvedPath}`);
52
- }
68
+ const content = fs.readFileSync(resolvedPath, 'utf8');
69
+ const secrets = yaml.load(content);
53
70
 
54
- const content = fs.readFileSync(resolvedPath, 'utf8');
55
- const secrets = yaml.load(content);
71
+ if (!secrets || typeof secrets !== 'object') {
72
+ throw new Error(`Invalid secrets file format: ${resolvedPath}`);
73
+ }
56
74
 
57
- if (!secrets || typeof secrets !== 'object') {
58
- throw new Error(`Invalid secrets file format: ${resolvedPath}`);
75
+ return secrets;
59
76
  }
60
77
 
61
- return secrets;
62
- }
78
+ // Cascading lookup: user's file first
79
+ let mergedSecrets = loadUserSecrets();
63
80
 
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
- }
81
+ // Then check build.secrets from variables.yaml if appName provided
82
+ if (appName) {
83
+ mergedSecrets = await loadBuildSecrets(mergedSecrets, appName);
84
+ }
91
85
 
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);
86
+ // If still no secrets found, try default location
87
+ if (Object.keys(mergedSecrets).length === 0) {
88
+ mergedSecrets = loadDefaultSecrets();
89
+ }
90
+
91
+ // If still empty, throw error
92
+ if (Object.keys(mergedSecrets).length === 0) {
93
+ throw new Error('No secrets file found. Please create ~/.aifabrix/secrets.local.yaml or configure build.secrets in variables.yaml');
98
94
  }
99
95
 
100
- return resolvedPath;
96
+ return mergedSecrets;
101
97
  }
102
98
 
103
99
  /**
@@ -109,7 +105,9 @@ function resolveSecretsPath(secretsPath) {
109
105
  * @param {string} envTemplate - Environment template content
110
106
  * @param {Object} secrets - Secrets object from loadSecrets()
111
107
  * @param {string} [environment='local'] - Environment context (docker/local)
112
- * @param {string} [secretsFilePath] - Path to secrets file (for error messages)
108
+ * @param {Object|string|null} [secretsFilePaths] - Paths object with userPath and buildPath, or string path (for backward compatibility)
109
+ * @param {string} [secretsFilePaths.userPath] - User's secrets file path
110
+ * @param {string|null} [secretsFilePaths.buildPath] - App's build.secrets file path (if configured)
113
111
  * @returns {Promise<string>} Resolved environment content
114
112
  * @throws {Error} If kv:// reference cannot be resolved
115
113
  *
@@ -117,7 +115,7 @@ function resolveSecretsPath(secretsPath) {
117
115
  * const resolved = await resolveKvReferences(template, secrets, 'local');
118
116
  * // Returns: 'DATABASE_URL=postgresql://user:pass@localhost:5432/db'
119
117
  */
120
- async function resolveKvReferences(envTemplate, secrets, environment = 'local', secretsFilePath = null) {
118
+ async function resolveKvReferences(envTemplate, secrets, environment = 'local', secretsFilePaths = null) {
121
119
  const envConfig = loadEnvConfig();
122
120
  const envVars = envConfig.environments[environment] || envConfig.environments.local;
123
121
 
@@ -138,7 +136,20 @@ async function resolveKvReferences(envTemplate, secrets, environment = 'local',
138
136
  }
139
137
 
140
138
  if (missingSecrets.length > 0) {
141
- const fileInfo = secretsFilePath ? `\n\nSecrets file location: ${secretsFilePath}` : '';
139
+ let fileInfo = '';
140
+ if (secretsFilePaths) {
141
+ // Handle backward compatibility: if it's a string, use it as-is
142
+ if (typeof secretsFilePaths === 'string') {
143
+ fileInfo = `\n\nSecrets file location: ${secretsFilePaths}`;
144
+ } else if (typeof secretsFilePaths === 'object' && secretsFilePaths.userPath) {
145
+ // New format: show both paths if buildPath is configured
146
+ const paths = [secretsFilePaths.userPath];
147
+ if (secretsFilePaths.buildPath) {
148
+ paths.push(secretsFilePaths.buildPath);
149
+ }
150
+ fileInfo = `\n\nSecrets file location: ${paths.join(' and ')}`;
151
+ }
152
+ }
142
153
  throw new Error(`Missing secrets: ${missingSecrets.join(', ')}${fileInfo}`);
143
154
  }
144
155
 
@@ -157,6 +168,36 @@ async function resolveKvReferences(envTemplate, secrets, environment = 'local',
157
168
  return resolved;
158
169
  }
159
170
 
171
+ /**
172
+ * Resolves service ports in URLs within .env content for Docker environment
173
+ * Replaces ports in URLs with containerPort from service's variables.yaml
174
+ *
175
+ * @function resolveServicePortsInEnvContent
176
+ * @param {string} envContent - Resolved .env file content
177
+ * @param {string} environment - Environment context (docker/local)
178
+ * @returns {string} Content with resolved ports
179
+ */
180
+ function resolveServicePortsInEnvContent(envContent, environment) {
181
+ // Only process docker environment
182
+ if (environment !== 'docker') {
183
+ return envContent;
184
+ }
185
+
186
+ const envConfig = loadEnvConfig();
187
+ const dockerHosts = envConfig.environments.docker || {};
188
+ const hostnameToService = buildHostnameToServiceMap(dockerHosts);
189
+
190
+ // Pattern to match URLs: http://hostname:port or https://hostname:port
191
+ // Matches: protocol://hostname:port/path?query
192
+ // Captures: protocol, hostname, port, and optional path/query
193
+ // Note: [^\s\n]* matches any non-whitespace characters except newline (stops at end of line)
194
+ const urlPattern = /(https?:\/\/)([a-zA-Z0-9-]+):(\d+)([^\s\n]*)?/g;
195
+
196
+ return envContent.replace(urlPattern, (match, protocol, hostname, port, urlPath = '') => {
197
+ return resolveUrlPort(protocol, hostname, port, urlPath || '', hostnameToService);
198
+ });
199
+ }
200
+
160
201
  /**
161
202
  * Loads environment template from file
162
203
  * @function loadEnvTemplate
@@ -246,15 +287,21 @@ async function generateEnvFile(appName, secretsPath, environment = 'local', forc
246
287
 
247
288
  const template = loadEnvTemplate(templatePath);
248
289
 
249
- // Resolve secrets path to show in error messages
250
- const resolvedSecretsPath = resolveSecretsPath(secretsPath);
290
+ // Resolve secrets paths to show in error messages (use actual paths that loadSecrets would use)
291
+ const secretsPaths = getActualSecretsPath(secretsPath, appName);
251
292
 
252
293
  if (force) {
253
- await generateMissingSecrets(template, resolvedSecretsPath);
294
+ // Use userPath for generating missing secrets (priority file)
295
+ await generateMissingSecrets(template, secretsPaths.userPath);
254
296
  }
255
297
 
256
- const secrets = await loadSecrets(secretsPath);
257
- const resolved = await resolveKvReferences(template, secrets, environment, resolvedSecretsPath);
298
+ const secrets = await loadSecrets(secretsPath, appName);
299
+ let resolved = await resolveKvReferences(template, secrets, environment, secretsPaths);
300
+
301
+ // Resolve service ports in URLs for docker environment
302
+ if (environment === 'docker') {
303
+ resolved = resolveServicePortsInEnvContent(resolved, environment);
304
+ }
258
305
 
259
306
  fs.writeFileSync(envPath, resolved, { mode: 0o600 });
260
307
 
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Environment Template Utilities
3
+ *
4
+ * Helper functions for updating env.template files
5
+ *
6
+ * @fileoverview Environment template update utilities
7
+ * @author AI Fabrix Team
8
+ * @version 2.0.0
9
+ */
10
+
11
+ const fs = require('fs').promises;
12
+ const fsSync = require('fs');
13
+ const path = require('path');
14
+ const chalk = require('chalk');
15
+ const logger = require('../utils/logger');
16
+
17
+ /**
18
+ * Updates env.template to add MISO_CLIENTID and MISO_CLIENTSECRET entries
19
+ * @async
20
+ * @param {string} appKey - Application key
21
+ * @param {string} clientIdKey - Secret key for client ID (e.g., 'myapp-client-idKeyVault')
22
+ * @param {string} clientSecretKey - Secret key for client secret (e.g., 'myapp-client-secretKeyVault')
23
+ * @returns {Promise<void>} Resolves when template is updated
24
+ */
25
+ async function updateEnvTemplate(appKey, clientIdKey, clientSecretKey) {
26
+ const envTemplatePath = path.join(process.cwd(), 'builder', appKey, 'env.template');
27
+
28
+ if (!fsSync.existsSync(envTemplatePath)) {
29
+ logger.warn(chalk.yellow(`⚠️ env.template not found for ${appKey}, skipping update`));
30
+ return;
31
+ }
32
+
33
+ try {
34
+ let content = await fs.readFile(envTemplatePath, 'utf8');
35
+
36
+ // Check if MISO_CLIENTID already exists
37
+ const hasClientId = /^MISO_CLIENTID\s*=/m.test(content);
38
+ const hasClientSecret = /^MISO_CLIENTSECRET\s*=/m.test(content);
39
+
40
+ if (hasClientId && hasClientSecret) {
41
+ // Update existing entries
42
+ content = content.replace(/^MISO_CLIENTID\s*=.*$/m, `MISO_CLIENTID=kv://${clientIdKey}`);
43
+ content = content.replace(/^MISO_CLIENTSECRET\s*=.*$/m, `MISO_CLIENTSECRET=kv://${clientSecretKey}`);
44
+ } else {
45
+ // Add new section if not present
46
+ const misoSection = `# MISO Application Client Credentials (per application)
47
+ MISO_CLIENTID=kv://${clientIdKey}
48
+ MISO_CLIENTSECRET=kv://${clientSecretKey}
49
+ `;
50
+
51
+ // Try to find a good place to insert (after last section or at end)
52
+ const lastSectionMatch = content.match(/# =+.*$/gm);
53
+ if (lastSectionMatch && lastSectionMatch.length > 0) {
54
+ const lastSectionIndex = content.lastIndexOf(lastSectionMatch[lastSectionMatch.length - 1]);
55
+ const insertIndex = content.indexOf('\n', lastSectionIndex) + 1;
56
+ content = content.slice(0, insertIndex) + '\n' + misoSection + content.slice(insertIndex);
57
+ } else {
58
+ content = content + '\n' + misoSection;
59
+ }
60
+ }
61
+
62
+ await fs.writeFile(envTemplatePath, content, 'utf8');
63
+ logger.log(chalk.green(`✓ Updated env.template for ${appKey}`));
64
+ } catch (error) {
65
+ logger.warn(chalk.yellow(`⚠️ Could not update env.template: ${error.message}`));
66
+ }
67
+ }
68
+
69
+ module.exports = { updateEnvTemplate };
70
+
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Local Secrets Management Utilities
3
+ *
4
+ * Helper functions for managing local secrets in ~/.aifabrix/secrets.local.yaml
5
+ *
6
+ * @fileoverview Local secrets management utilities
7
+ * @author AI Fabrix Team
8
+ * @version 2.0.0
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const yaml = require('js-yaml');
14
+ const os = require('os');
15
+ const chalk = require('chalk');
16
+ const logger = require('../utils/logger');
17
+
18
+ /**
19
+ * Saves a secret to ~/.aifabrix/secrets.local.yaml
20
+ * Merges with existing secrets without overwriting other keys
21
+ *
22
+ * @async
23
+ * @function saveLocalSecret
24
+ * @param {string} key - Secret key name
25
+ * @param {string} value - Secret value
26
+ * @returns {Promise<void>} Resolves when secret is saved
27
+ * @throws {Error} If save fails
28
+ *
29
+ * @example
30
+ * await saveLocalSecret('myapp-client-idKeyVault', 'client-id-value');
31
+ */
32
+ async function saveLocalSecret(key, value) {
33
+ if (!key || typeof key !== 'string') {
34
+ throw new Error('Secret key is required and must be a string');
35
+ }
36
+
37
+ if (value === undefined || value === null) {
38
+ throw new Error('Secret value is required');
39
+ }
40
+
41
+ const secretsPath = path.join(os.homedir(), '.aifabrix', 'secrets.local.yaml');
42
+ const secretsDir = path.dirname(secretsPath);
43
+
44
+ // Create directory if needed
45
+ if (!fs.existsSync(secretsDir)) {
46
+ fs.mkdirSync(secretsDir, { recursive: true, mode: 0o700 });
47
+ }
48
+
49
+ // Load existing secrets
50
+ let existingSecrets = {};
51
+ if (fs.existsSync(secretsPath)) {
52
+ try {
53
+ const content = fs.readFileSync(secretsPath, 'utf8');
54
+ existingSecrets = yaml.load(content) || {};
55
+ if (typeof existingSecrets !== 'object') {
56
+ existingSecrets = {};
57
+ }
58
+ } catch (error) {
59
+ logger.warn(`Warning: Could not read existing secrets file: ${error.message}`);
60
+ existingSecrets = {};
61
+ }
62
+ }
63
+
64
+ // Merge with new secret
65
+ const updatedSecrets = {
66
+ ...existingSecrets,
67
+ [key]: value
68
+ };
69
+
70
+ // Save to file
71
+ const yamlContent = yaml.dump(updatedSecrets, {
72
+ indent: 2,
73
+ lineWidth: 120,
74
+ noRefs: true,
75
+ sortKeys: false
76
+ });
77
+
78
+ fs.writeFileSync(secretsPath, yamlContent, { mode: 0o600 });
79
+ logger.log(chalk.green(`✓ Saved secret ${key} to ${secretsPath}`));
80
+ }
81
+
82
+ /**
83
+ * Checks if a URL is localhost
84
+ * @function isLocalhost
85
+ * @param {string} url - URL to check
86
+ * @returns {boolean} True if URL is localhost
87
+ */
88
+ function isLocalhost(url) {
89
+ if (!url || typeof url !== 'string') {
90
+ return false;
91
+ }
92
+
93
+ const urlLower = url.toLowerCase();
94
+ return urlLower.includes('localhost') || urlLower.includes('127.0.0.1');
95
+ }
96
+
97
+ module.exports = { saveLocalSecret, isLocalhost };
98
+
@@ -0,0 +1,111 @@
1
+ /**
2
+ * AI Fabrix Builder Secrets Path Resolution
3
+ *
4
+ * This module handles secrets file path resolution with cascading lookup support.
5
+ * Determines the actual path that would be used for loading secrets.
6
+ *
7
+ * @fileoverview Secrets path resolution utilities 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
+
17
+ /**
18
+ * Resolves secrets file path (backward compatibility)
19
+ * Checks common locations if path is not provided
20
+ * @function resolveSecretsPath
21
+ * @param {string} [secretsPath] - Path to secrets file (optional)
22
+ * @returns {string} Resolved secrets file path
23
+ */
24
+ function resolveSecretsPath(secretsPath) {
25
+ let resolvedPath = secretsPath;
26
+
27
+ if (!resolvedPath) {
28
+ // Check common locations for secrets.local.yaml
29
+ const commonLocations = [
30
+ path.join(process.cwd(), '..', 'aifabrix-setup', 'secrets.local.yaml'),
31
+ path.join(process.cwd(), '..', '..', 'aifabrix-setup', 'secrets.local.yaml'),
32
+ path.join(process.cwd(), 'secrets.local.yaml'),
33
+ path.join(process.cwd(), '..', 'secrets.local.yaml'),
34
+ path.join(os.homedir(), '.aifabrix', 'secrets.yaml')
35
+ ];
36
+
37
+ // Find first existing file
38
+ for (const location of commonLocations) {
39
+ if (fs.existsSync(location)) {
40
+ resolvedPath = location;
41
+ break;
42
+ }
43
+ }
44
+
45
+ // If none found, use default location
46
+ if (!resolvedPath) {
47
+ resolvedPath = path.join(os.homedir(), '.aifabrix', 'secrets.yaml');
48
+ }
49
+ } else if (secretsPath.startsWith('..')) {
50
+ resolvedPath = path.resolve(process.cwd(), secretsPath);
51
+ }
52
+
53
+ return resolvedPath;
54
+ }
55
+
56
+ /**
57
+ * Determines the actual secrets file paths that loadSecrets would use
58
+ * Mirrors the cascading lookup logic from loadSecrets
59
+ * @function getActualSecretsPath
60
+ * @param {string} [secretsPath] - Path to secrets file (optional)
61
+ * @param {string} [appName] - Application name (optional, for variables.yaml lookup)
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)
65
+ */
66
+ function getActualSecretsPath(secretsPath, appName) {
67
+ // If explicit path provided, use it (backward compatibility)
68
+ if (secretsPath) {
69
+ const resolvedPath = resolveSecretsPath(secretsPath);
70
+ return {
71
+ userPath: resolvedPath,
72
+ buildPath: null
73
+ };
74
+ }
75
+
76
+ // Cascading lookup: user's file first
77
+ const userSecretsPath = path.join(os.homedir(), '.aifabrix', 'secrets.local.yaml');
78
+
79
+ // Check build.secrets from variables.yaml if appName provided
80
+ let buildSecretsPath = null;
81
+ if (appName) {
82
+ const variablesPath = path.join(process.cwd(), 'builder', appName, 'variables.yaml');
83
+ if (fs.existsSync(variablesPath)) {
84
+ try {
85
+ const variablesContent = fs.readFileSync(variablesPath, 'utf8');
86
+ const variables = yaml.load(variablesContent);
87
+
88
+ if (variables?.build?.secrets) {
89
+ buildSecretsPath = path.resolve(
90
+ path.dirname(variablesPath),
91
+ variables.build.secrets
92
+ );
93
+ }
94
+ } catch (error) {
95
+ // Ignore errors, continue
96
+ }
97
+ }
98
+ }
99
+
100
+ // Return both paths (even if files don't exist) for error messages
101
+ return {
102
+ userPath: userSecretsPath,
103
+ buildPath: buildSecretsPath
104
+ };
105
+ }
106
+
107
+ module.exports = {
108
+ resolveSecretsPath,
109
+ getActualSecretsPath
110
+ };
111
+