@aifabrix/builder 2.37.5 → 2.38.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 (51) hide show
  1. package/README.md +19 -0
  2. package/integration/hubspot/hubspot-deploy.json +1 -2
  3. package/lib/api/applications.api.js +23 -1
  4. package/lib/api/credentials.api.js +34 -0
  5. package/lib/api/deployments.api.js +27 -0
  6. package/lib/api/types/applications.types.js +1 -1
  7. package/lib/api/types/deployments.types.js +1 -1
  8. package/lib/api/types/pipeline.types.js +1 -1
  9. package/lib/api/wizard.api.js +21 -1
  10. package/lib/app/run-helpers.js +30 -2
  11. package/lib/cli/index.js +2 -0
  12. package/lib/cli/setup-app.js +32 -0
  13. package/lib/cli/setup-credential-deployment.js +72 -0
  14. package/lib/cli/setup-utility.js +1 -25
  15. package/lib/commands/app-down.js +80 -0
  16. package/lib/commands/app-logs.js +146 -0
  17. package/lib/commands/app.js +22 -0
  18. package/lib/commands/credential-list.js +104 -0
  19. package/lib/commands/deployment-list.js +184 -0
  20. package/lib/commands/up-miso.js +2 -2
  21. package/lib/commands/wizard-core.js +39 -27
  22. package/lib/core/config.js +16 -1
  23. package/lib/core/secrets.js +42 -50
  24. package/lib/core/templates.js +2 -1
  25. package/lib/deployment/environment.js +32 -21
  26. package/lib/generator/builders.js +8 -3
  27. package/lib/generator/external-controller-manifest.js +5 -4
  28. package/lib/generator/index.js +16 -14
  29. package/lib/generator/split.js +1 -0
  30. package/lib/generator/wizard.js +4 -1
  31. package/lib/schema/application-schema.json +6 -2
  32. package/lib/schema/deployment-rules.yaml +121 -0
  33. package/lib/utils/app-run-containers.js +2 -1
  34. package/lib/utils/compose-generator.js +2 -1
  35. package/lib/utils/help-builder.js +0 -1
  36. package/lib/utils/image-version.js +209 -0
  37. package/lib/utils/paths.js +6 -3
  38. package/lib/utils/schema-loader.js +1 -1
  39. package/lib/utils/variable-transformer.js +1 -19
  40. package/lib/validation/external-manifest-validator.js +1 -1
  41. package/package.json +1 -1
  42. package/templates/applications/README.md.hbs +1 -3
  43. package/templates/applications/dataplane/Dockerfile +2 -2
  44. package/templates/applications/dataplane/README.md +1 -3
  45. package/templates/applications/dataplane/variables.yaml +5 -3
  46. package/templates/applications/keycloak/Dockerfile +3 -3
  47. package/templates/applications/keycloak/README.md +14 -4
  48. package/templates/applications/keycloak/env.template +14 -2
  49. package/templates/applications/keycloak/variables.yaml +1 -1
  50. package/templates/applications/miso-controller/README.md +1 -3
  51. package/templates/applications/miso-controller/env.template +64 -11
@@ -15,6 +15,7 @@ const { listApplications } = require('../app/list');
15
15
  const { registerApplication } = require('../app/register');
16
16
  const { rotateSecret } = require('../app/rotate-secret');
17
17
  const { showApp } = require('../app/show');
18
+ const { runAppDeploymentList } = require('./deployment-list');
18
19
 
19
20
  /**
20
21
  * Setup application management commands
@@ -81,6 +82,27 @@ function setupAppCommands(program) {
81
82
  process.exit(1);
82
83
  }
83
84
  });
85
+
86
+ // Deployment list for an application
87
+ app
88
+ .command('deployment <appKey>')
89
+ .description('List last N deployments for an application in current environment (default pageSize=50)')
90
+ .option('--controller <url>', 'Controller URL (default: from config)')
91
+ .option('--environment <env>', 'Environment key (default: from config)')
92
+ .option('--page-size <n>', 'Items per page', '50')
93
+ .action(async(appKey, options) => {
94
+ try {
95
+ const opts = {
96
+ controller: options.controller,
97
+ environment: options.environment,
98
+ pageSize: parseInt(options.pageSize, 10) || 50
99
+ };
100
+ await runAppDeploymentList(appKey, opts);
101
+ } catch (error) {
102
+ logger.error(chalk.red(`Error: ${error.message}`));
103
+ process.exit(1);
104
+ }
105
+ });
84
106
  }
85
107
 
86
108
  module.exports = { setupAppCommands };
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Credential list command – list credentials from controller/dataplane
3
+ * GET /api/v1/credential. Used by `aifabrix credential list`.
4
+ *
5
+ * @fileoverview Credential list command implementation
6
+ * @author AI Fabrix Team
7
+ * @version 2.0.0
8
+ */
9
+
10
+ const chalk = require('chalk');
11
+ const logger = require('../utils/logger');
12
+ const { resolveControllerUrl } = require('../utils/controller-url');
13
+ const { getOrRefreshDeviceToken } = require('../utils/token-manager');
14
+ const { normalizeControllerUrl } = require('../core/config');
15
+ const { listCredentials } = require('../api/credentials.api');
16
+
17
+ const DEFAULT_PAGE_SIZE = 50;
18
+
19
+ /**
20
+ * Get auth token for credential list (device token from config)
21
+ * @async
22
+ * @param {string} controllerUrl - Controller base URL
23
+ * @returns {Promise<{token: string, controllerUrl: string}|null>}
24
+ */
25
+ async function getCredentialListAuth(controllerUrl) {
26
+ const normalizedUrl = normalizeControllerUrl(controllerUrl);
27
+ const deviceToken = await getOrRefreshDeviceToken(normalizedUrl);
28
+ if (deviceToken && deviceToken.token) {
29
+ return {
30
+ token: deviceToken.token,
31
+ controllerUrl: deviceToken.controller || normalizedUrl
32
+ };
33
+ }
34
+ return null;
35
+ }
36
+
37
+ /**
38
+ * Extract credentials array from API response
39
+ * @param {Object} response - API response
40
+ * @returns {Array}
41
+ */
42
+ function extractCredentials(response) {
43
+ const data = response?.data ?? response;
44
+ const items = data?.credentials ?? data?.items ?? (Array.isArray(data) ? data : []);
45
+ return Array.isArray(items) ? items : [];
46
+ }
47
+
48
+ /**
49
+ * Display credential list to user
50
+ * @param {Array} list - Credentials array
51
+ * @param {string} controllerUrl - Controller URL for header
52
+ */
53
+ function displayCredentialList(list, controllerUrl) {
54
+ logger.log(chalk.bold(`\n🔐 Credentials (${controllerUrl}):\n`));
55
+ if (list.length === 0) {
56
+ logger.log(chalk.gray(' No credentials found.\n'));
57
+ return;
58
+ }
59
+ list.forEach((c) => {
60
+ const key = c.key ?? c.id ?? c.credentialKey ?? '-';
61
+ const name = c.displayName ?? c.name ?? key;
62
+ logger.log(` ${chalk.cyan(key)} - ${name}`);
63
+ });
64
+ logger.log('');
65
+ }
66
+
67
+ /**
68
+ * Run credential list command: call GET /api/v1/credential and display results
69
+ * @async
70
+ * @param {Object} options - CLI options
71
+ * @param {string} [options.controller] - Controller URL override
72
+ * @param {boolean} [options.activeOnly] - List only active credentials
73
+ * @param {number} [options.pageSize] - Items per page
74
+ * @returns {Promise<void>}
75
+ */
76
+ async function runCredentialList(options = {}) {
77
+ const controllerUrl = options.controller || (await resolveControllerUrl());
78
+ if (!controllerUrl) {
79
+ logger.error(chalk.red('❌ Controller URL is required. Run "aifabrix login" first.'));
80
+ process.exit(1);
81
+ return;
82
+ }
83
+ const authResult = await getCredentialListAuth(controllerUrl);
84
+ if (!authResult || !authResult.token) {
85
+ logger.error(chalk.red(`❌ No authentication token for controller: ${controllerUrl}`));
86
+ logger.error(chalk.gray('Run: aifabrix login'));
87
+ process.exit(1);
88
+ return;
89
+ }
90
+ const authConfig = { type: 'bearer', token: authResult.token };
91
+ const listOptions = {
92
+ pageSize: options.pageSize || DEFAULT_PAGE_SIZE,
93
+ activeOnly: options.activeOnly
94
+ };
95
+ try {
96
+ const response = await listCredentials(authResult.controllerUrl, authConfig, listOptions);
97
+ displayCredentialList(extractCredentials(response), authResult.controllerUrl);
98
+ } catch (error) {
99
+ logger.error(chalk.red(`❌ Failed to list credentials: ${error.message}`));
100
+ process.exit(1);
101
+ }
102
+ }
103
+
104
+ module.exports = { runCredentialList };
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Deployment list commands – list deployments for environment or for an app
3
+ * Uses GET .../deployments and GET .../applications/{appKey}/deployments.
4
+ *
5
+ * @fileoverview Deployment list command implementation
6
+ * @author AI Fabrix Team
7
+ * @version 2.0.0
8
+ */
9
+
10
+ const chalk = require('chalk');
11
+ const logger = require('../utils/logger');
12
+ const { resolveControllerUrl } = require('../utils/controller-url');
13
+ const { resolveEnvironment } = require('../core/config');
14
+ const { getOrRefreshDeviceToken } = require('../utils/token-manager');
15
+ const { listDeployments, listApplicationDeployments } = require('../api/deployments.api');
16
+ const { normalizeControllerUrl } = require('../core/config');
17
+
18
+ const DEFAULT_PAGE_SIZE = 50;
19
+
20
+ /**
21
+ * Get auth token for deployment list (device token from config)
22
+ * @async
23
+ * @param {string} controllerUrl - Controller base URL
24
+ * @returns {Promise<{token: string, controllerUrl: string}|null>}
25
+ */
26
+ async function getDeploymentListAuth(controllerUrl) {
27
+ const normalizedUrl = normalizeControllerUrl(controllerUrl);
28
+ const deviceToken = await getOrRefreshDeviceToken(normalizedUrl);
29
+ if (deviceToken && deviceToken.token) {
30
+ return {
31
+ token: deviceToken.token,
32
+ controllerUrl: deviceToken.controller || normalizedUrl
33
+ };
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * Extract deployments array from API response
40
+ * @param {Object} response - API response
41
+ * @returns {Array}
42
+ */
43
+ function extractDeployments(response) {
44
+ const data = response?.data ?? response;
45
+ const items = data?.items ?? data?.deployments ?? (Array.isArray(data) ? data : []);
46
+ return Array.isArray(items) ? items : [];
47
+ }
48
+
49
+ /**
50
+ * Display environment deployment list to user
51
+ * @param {Array} deployments - Deployments array
52
+ * @param {string} environment - Environment key
53
+ * @param {string} controllerUrl - Controller URL
54
+ */
55
+ function displayDeploymentList(deployments, environment, controllerUrl) {
56
+ logger.log(chalk.bold(`\n📋 Deployments (${environment}) at ${controllerUrl}:\n`));
57
+ if (deployments.length === 0) {
58
+ logger.log(chalk.gray(' No deployments found.\n'));
59
+ return;
60
+ }
61
+ deployments.forEach((d) => {
62
+ const id = d.id ?? d.deploymentId ?? '-';
63
+ const appKey = d.applicationKey ?? d.appKey ?? d.application?.key ?? '-';
64
+ const status = d.status ?? '-';
65
+ const createdAt = d.createdAt ?? d.created ?? '';
66
+ logger.log(` ${chalk.cyan(id)} ${appKey} ${status} ${chalk.gray(createdAt)}`);
67
+ });
68
+ logger.log('');
69
+ }
70
+
71
+ /**
72
+ * Run deployment list (environment): list last N deployments for current environment
73
+ * @async
74
+ * @param {Object} options - CLI options
75
+ * @param {string} [options.controller] - Controller URL override
76
+ * @param {string} [options.environment] - Environment key override
77
+ * @param {number} [options.pageSize] - Items per page (default 50)
78
+ * @returns {Promise<void>}
79
+ */
80
+ async function runDeploymentList(options = {}) {
81
+ const { environment, authResult } = await resolveDeploymentListContext(options);
82
+ const authConfig = { type: 'bearer', token: authResult.token };
83
+ const listOptions = { pageSize: options.pageSize || DEFAULT_PAGE_SIZE };
84
+ try {
85
+ const response = await listDeployments(
86
+ authResult.controllerUrl,
87
+ environment,
88
+ authConfig,
89
+ listOptions
90
+ );
91
+ displayDeploymentList(extractDeployments(response), environment, authResult.controllerUrl);
92
+ } catch (error) {
93
+ logger.error(chalk.red(`❌ Failed to list deployments: ${error.message}`));
94
+ process.exit(1);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Display app deployment list to user
100
+ * @param {Array} deployments - Deployments array
101
+ * @param {string} appKey - Application key
102
+ * @param {string} environment - Environment key
103
+ * @param {string} controllerUrl - Controller URL
104
+ */
105
+ function displayAppDeploymentList(deployments, appKey, environment, controllerUrl) {
106
+ logger.log(chalk.bold(`\n📋 Deployments for ${appKey} (${environment}) at ${controllerUrl}:\n`));
107
+ if (deployments.length === 0) {
108
+ logger.log(chalk.gray(' No deployments found for this application.\n'));
109
+ return;
110
+ }
111
+ deployments.forEach((d) => {
112
+ const id = d.id ?? d.deploymentId ?? '-';
113
+ const status = d.status ?? '-';
114
+ const createdAt = d.createdAt ?? d.created ?? '';
115
+ logger.log(` ${chalk.cyan(id)} ${status} ${chalk.gray(createdAt)}`);
116
+ });
117
+ logger.log('');
118
+ }
119
+
120
+ /**
121
+ * Resolve controller URL, environment, and auth for deployment list commands
122
+ * @async
123
+ * @param {Object} options - Options with optional controller, environment
124
+ * @returns {Promise<{controllerUrl: string, environment: string, authResult: Object}>}
125
+ */
126
+ async function resolveDeploymentListContext(options) {
127
+ const controllerUrl = options.controller || (await resolveControllerUrl());
128
+ if (!controllerUrl) {
129
+ logger.error(chalk.red('❌ Controller URL is required. Run "aifabrix login" first.'));
130
+ process.exit(1);
131
+ }
132
+ const environment = options.environment || (await resolveEnvironment());
133
+ const authResult = await getDeploymentListAuth(controllerUrl);
134
+ if (!authResult || !authResult.token) {
135
+ logger.error(chalk.red(`❌ No authentication token for controller: ${controllerUrl}`));
136
+ logger.error(chalk.gray('Run: aifabrix login'));
137
+ process.exit(1);
138
+ }
139
+ return { controllerUrl, environment, authResult };
140
+ }
141
+
142
+ /**
143
+ * Run app deployment list: list last N deployments for an application
144
+ * @async
145
+ * @param {string} appKey - Application key
146
+ * @param {Object} options - CLI options
147
+ * @param {string} [options.controller] - Controller URL override
148
+ * @param {string} [options.environment] - Environment key override
149
+ * @param {number} [options.pageSize] - Items per page (default 50)
150
+ * @returns {Promise<void>}
151
+ */
152
+ async function runAppDeploymentList(appKey, options = {}) {
153
+ if (!appKey || typeof appKey !== 'string') {
154
+ logger.error(chalk.red('❌ Application key is required.'));
155
+ process.exit(1);
156
+ return;
157
+ }
158
+ const { environment, authResult } = await resolveDeploymentListContext(options);
159
+ const authConfig = { type: 'bearer', token: authResult.token };
160
+ const listOptions = { pageSize: options.pageSize || DEFAULT_PAGE_SIZE };
161
+ try {
162
+ const response = await listApplicationDeployments(
163
+ authResult.controllerUrl,
164
+ environment,
165
+ appKey,
166
+ authConfig,
167
+ listOptions
168
+ );
169
+ displayAppDeploymentList(
170
+ extractDeployments(response),
171
+ appKey,
172
+ environment,
173
+ authResult.controllerUrl
174
+ );
175
+ } catch (error) {
176
+ logger.error(chalk.red(`❌ Failed to list deployments for ${appKey}: ${error.message}`));
177
+ process.exit(1);
178
+ }
179
+ }
180
+
181
+ module.exports = {
182
+ runDeploymentList,
183
+ runAppDeploymentList
184
+ };
@@ -140,8 +140,8 @@ async function handleUpMiso(options = {}) {
140
140
  const devIdNum = typeof developerId === 'string' ? parseInt(developerId, 10) : developerId;
141
141
  await setMisoSecretsAndResolve(devIdNum);
142
142
  await runMisoApps(options);
143
- logger.log(chalk.green('\n✓ up-miso complete. Keycloak, miso-controller, and dataplane are running.'));
144
- logger.log(chalk.gray(' Run onboarding and register Keycloak from the miso-controller repo if needed.'));
143
+ logger.log(chalk.green('\n✓ up-miso complete. Keycloak, miso-controller, and dataplane are running.') +
144
+ chalk.gray('\n Run onboarding and register Keycloak from the miso-controller repo if needed.'));
145
145
  }
146
146
 
147
147
  module.exports = { handleUpMiso, parseImageOptions };
@@ -338,6 +338,44 @@ async function validateWizardConfiguration(dataplaneUrl, authConfig, systemConfi
338
338
  }
339
339
  }
340
340
 
341
+ /**
342
+ * Fetches deployment docs and writes README.md when variables.yaml and deploy JSON are available.
343
+ * @async
344
+ * @param {string} appPath - Application path
345
+ * @param {string} appName - Application name
346
+ * @param {string} dataplaneUrl - Dataplane URL
347
+ * @param {Object} authConfig - Authentication configuration
348
+ * @param {string} systemKey - System key
349
+ */
350
+ async function tryUpdateReadmeFromDeploymentDocs(appPath, appName, dataplaneUrl, authConfig, systemKey) {
351
+ const variablesPath = path.join(appPath, 'variables.yaml');
352
+ const deployPath = path.join(appPath, `${appName}-deploy.json`);
353
+ let variablesYaml = null;
354
+ let deployJson = null;
355
+ try {
356
+ variablesYaml = await fs.readFile(variablesPath, 'utf8');
357
+ } catch {
358
+ // optional
359
+ }
360
+ try {
361
+ const deployContent = await fs.readFile(deployPath, 'utf8');
362
+ deployJson = JSON.parse(deployContent);
363
+ } catch {
364
+ // optional
365
+ }
366
+ const hasBody = variablesYaml !== null || deployJson !== null;
367
+ const body = hasBody ? { variablesYaml: variablesYaml || null, deployJson: deployJson || null } : null;
368
+ const docsResponse = body
369
+ ? await postDeploymentDocs(dataplaneUrl, authConfig, systemKey, body)
370
+ : await getDeploymentDocs(dataplaneUrl, authConfig, systemKey);
371
+ const content = docsResponse?.data?.content ?? docsResponse?.content;
372
+ if (content && typeof content === 'string') {
373
+ const readmePath = path.join(appPath, 'README.md');
374
+ await fs.writeFile(readmePath, content, 'utf8');
375
+ logger.log(chalk.gray(' Updated README.md from deployment-docs API (variables.yaml + deploy JSON).'));
376
+ }
377
+ }
378
+
341
379
  /**
342
380
  * Handle file saving step
343
381
  * @async
@@ -357,33 +395,7 @@ async function handleFileSaving(appName, systemConfig, datasourceConfigs, system
357
395
  const generatedFiles = await generateWizardFiles(appName, systemConfig, datasourceConfigs, systemKey, { aiGeneratedReadme: null });
358
396
  if (systemKey && dataplaneUrl && authConfig && generatedFiles.appPath) {
359
397
  try {
360
- const appPath = generatedFiles.appPath;
361
- const deployKey = appName;
362
- const variablesPath = path.join(appPath, 'variables.yaml');
363
- const deployPath = path.join(appPath, `${deployKey}-deploy.json`);
364
- let variablesYaml = null;
365
- let deployJson = null;
366
- try {
367
- variablesYaml = await fs.readFile(variablesPath, 'utf8');
368
- } catch {
369
- // optional
370
- }
371
- try {
372
- const deployContent = await fs.readFile(deployPath, 'utf8');
373
- deployJson = JSON.parse(deployContent);
374
- } catch {
375
- // optional
376
- }
377
- const body = (variablesYaml !== null && variablesYaml !== undefined) || (deployJson !== null && deployJson !== undefined) ? { variablesYaml: variablesYaml || null, deployJson: deployJson || null } : null;
378
- const docsResponse = body
379
- ? await postDeploymentDocs(dataplaneUrl, authConfig, systemKey, body)
380
- : await getDeploymentDocs(dataplaneUrl, authConfig, systemKey);
381
- const content = docsResponse?.data?.content ?? docsResponse?.content;
382
- if (content && typeof content === 'string') {
383
- const readmePath = path.join(appPath, 'README.md');
384
- await fs.writeFile(readmePath, content, 'utf8');
385
- logger.log(chalk.gray(' Updated README.md from deployment-docs API (variables.yaml + deploy JSON).'));
386
- }
398
+ await tryUpdateReadmeFromDeploymentDocs(generatedFiles.appPath, appName, dataplaneUrl, authConfig, systemKey);
387
399
  } catch (e) {
388
400
  logger.log(chalk.gray(` Could not fetch AI-generated README: ${e.message}`));
389
401
  }
@@ -405,9 +405,24 @@ async function ensureSecretsEncryptionKey() {
405
405
  await run({ getSecretsEncryptionKey, setSecretsEncryptionKey, getSecretsPath });
406
406
  }
407
407
 
408
+ /**
409
+ * Expand leading ~ to home directory so config paths like ~/.aifabrix/secrets.local.yaml resolve correctly.
410
+ * @param {string} filePath - Path that may start with ~ or ~/
411
+ * @returns {string} Path with ~ expanded, or unchanged if no leading ~
412
+ */
413
+ function expandTilde(filePath) {
414
+ if (!filePath || typeof filePath !== 'string') return filePath;
415
+ if (filePath === '~') return os.homedir();
416
+ if (filePath.startsWith('~/') || filePath.startsWith('~' + path.sep)) {
417
+ return path.join(os.homedir(), filePath.slice(2));
418
+ }
419
+ return filePath;
420
+ }
421
+
408
422
  async function getSecretsPath() {
409
423
  const config = await getConfig();
410
- return config['aifabrix-secrets'] || config['secrets-path'] || null;
424
+ const raw = config['aifabrix-secrets'] || config['secrets-path'] || null;
425
+ return raw ? expandTilde(raw) : null;
411
426
  }
412
427
 
413
428
  async function setSecretsPath(secretsPath) {
@@ -132,28 +132,57 @@ async function decryptSecretsObject(secrets) {
132
132
  * const secrets = await loadSecrets(undefined, 'myapp');
133
133
  */
134
134
 
135
+ /**
136
+ * Merges config file secrets into user secrets (user wins). Returns null if path missing or config empty.
137
+ * @param {Object} userSecrets - User secrets object
138
+ * @param {string} resolvedConfigPath - Absolute path to config secrets file
139
+ * @returns {Object|null} Merged secrets or null
140
+ */
141
+ function mergeUserWithConfigFile(userSecrets, resolvedConfigPath) {
142
+ if (!fs.existsSync(resolvedConfigPath)) {
143
+ return null;
144
+ }
145
+ let configSecrets;
146
+ try {
147
+ configSecrets = readYamlAtPath(resolvedConfigPath);
148
+ } catch (loadError) {
149
+ throw new Error(`Failed to load secrets file ${resolvedConfigPath}: ${loadError.message}`);
150
+ }
151
+ if (!configSecrets || typeof configSecrets !== 'object') {
152
+ return null;
153
+ }
154
+ const merged = { ...userSecrets };
155
+ for (const key of Object.keys(configSecrets)) {
156
+ if (!(key in merged) || merged[key] === undefined || merged[key] === null || merged[key] === '') {
157
+ merged[key] = configSecrets[key];
158
+ }
159
+ }
160
+ return merged;
161
+ }
162
+
135
163
  /**
136
164
  * Loads config secrets path, merges with user secrets (user overrides). Used by loadSecrets cascade.
137
165
  * @async
138
166
  * @returns {Promise<Object|null>} Merged secrets object or null
139
167
  */
140
168
  async function loadMergedConfigAndUserSecrets() {
169
+ const userSecrets = loadUserSecrets();
170
+ const hasKeys = (obj) => obj && Object.keys(obj).length > 0;
171
+ const userOrNull = () => (hasKeys(userSecrets) ? userSecrets : null);
141
172
  try {
142
173
  const configSecretsPath = await config.getSecretsPath();
143
- if (!configSecretsPath) return null;
174
+ if (!configSecretsPath) {
175
+ return userOrNull();
176
+ }
144
177
  const resolvedConfigPath = path.isAbsolute(configSecretsPath)
145
178
  ? configSecretsPath
146
179
  : path.resolve(process.cwd(), configSecretsPath);
147
- if (!fs.existsSync(resolvedConfigPath)) return null;
148
- const configSecrets = readYamlAtPath(resolvedConfigPath);
149
- if (!configSecrets || typeof configSecrets !== 'object') return null;
150
- const merged = { ...configSecrets };
151
- const userSecrets = loadUserSecrets();
152
- for (const key of Object.keys(userSecrets)) {
153
- merged[key] = userSecrets[key];
180
+ const merged = mergeUserWithConfigFile(userSecrets, resolvedConfigPath);
181
+ return merged !== null ? merged : userOrNull();
182
+ } catch (error) {
183
+ if (error.message && error.message.startsWith('Failed to load secrets file')) {
184
+ throw error;
154
185
  }
155
- return merged;
156
- } catch {
157
186
  return null;
158
187
  }
159
188
  }
@@ -229,22 +258,11 @@ async function resolveKvReferences(envTemplate, secrets, environment = 'local',
229
258
  return replaceKvInContent(resolved, secrets, envVars);
230
259
  }
231
260
 
232
- /**
233
- * Applies environment-specific transformations to resolved content
234
- * @async
235
- * @function applyEnvironmentTransformations
236
- * @param {string} resolved - Resolved environment content
237
- * @param {string} environment - Environment context
238
- * @param {string} variablesPath - Path to variables.yaml file
239
- * @returns {Promise<string>} Transformed content
240
- */
261
+ /** Applies environment-specific transformations to resolved content. */
241
262
  async function applyEnvironmentTransformations(resolved, environment, variablesPath) {
242
263
  if (environment === 'docker') {
243
264
  resolved = await resolveServicePortsInEnvContent(resolved, environment);
244
265
  resolved = await rewriteInfraEndpoints(resolved, 'docker');
245
- // Interpolate ${VAR} references created by rewriteInfraEndpoints
246
- // Get the actual host and port values from env-endpoints.js directly
247
- // to ensure they are correctly populated in envVars for interpolation
248
266
  const { getEnvHosts, getServiceHost, getServicePort, getLocalhostOverride } = require('../utils/env-endpoints');
249
267
  const hosts = await getEnvHosts('docker');
250
268
  const localhostOverride = getLocalhostOverride('docker');
@@ -252,10 +270,7 @@ async function applyEnvironmentTransformations(resolved, environment, variablesP
252
270
  const redisPort = await getServicePort('REDIS_PORT', 'redis', hosts, 'docker', null);
253
271
  const dbHost = getServiceHost(hosts.DB_HOST, 'docker', 'postgres', localhostOverride);
254
272
  const dbPort = await getServicePort('DB_PORT', 'postgres', hosts, 'docker', null);
255
-
256
- // Build envVars map and ensure it has the correct values
257
273
  const envVars = await buildEnvVarMap('docker');
258
- // Override with the actual values that were just set by rewriteInfraEndpoints
259
274
  envVars.REDIS_HOST = redisHost;
260
275
  envVars.REDIS_PORT = String(redisPort);
261
276
  envVars.DB_HOST = dbHost;
@@ -269,35 +284,15 @@ async function applyEnvironmentTransformations(resolved, environment, variablesP
269
284
  return resolved;
270
285
  }
271
286
 
272
- /**
273
- * Generates .env file content from template and secrets (without writing to disk)
274
- * @async
275
- * @function generateEnvContent
276
- * @param {string} appName - Name of the application
277
- * @param {string} [secretsPath] - Path to secrets file (optional)
278
- * @param {string} [environment='local'] - Environment context
279
- * @param {boolean} [force=false] - Generate missing secret keys in secrets file
280
- * @returns {Promise<string>} Generated .env file content
281
- * @throws {Error} If generation fails
282
- */
287
+ /** Generates .env file content from template and secrets (without writing to disk). */
283
288
  async function generateEnvContent(appName, secretsPath, environment = 'local', force = false) {
284
289
  const builderPath = pathsUtil.getBuilderPath(appName);
285
290
  const templatePath = path.join(builderPath, 'env.template');
286
291
  const variablesPath = path.join(builderPath, 'variables.yaml');
287
-
288
292
  const template = loadEnvTemplate(templatePath);
289
293
  const secretsPaths = await getActualSecretsPath(secretsPath, appName);
290
-
291
294
  if (force) {
292
- // Use the same path resolution logic as loadSecrets
293
- // If explicit path provided, use it; otherwise use the path that loadUserSecrets() would use
294
- let secretsFileForGeneration;
295
- if (secretsPath) {
296
- secretsFileForGeneration = resolveSecretsPath(secretsPath);
297
- } else {
298
- // Use the same path that loadUserSecrets() would use (now uses paths.getAifabrixHome())
299
- secretsFileForGeneration = secretsPaths.userPath;
300
- }
295
+ const secretsFileForGeneration = secretsPath ? resolveSecretsPath(secretsPath) : secretsPaths.userPath;
301
296
  await generateMissingSecrets(template, secretsFileForGeneration);
302
297
  }
303
298
 
@@ -472,9 +467,6 @@ REDIS_COMMANDER_PASSWORD=${postgresPassword}
472
467
  fs.writeFileSync(adminEnvPath, adminSecrets, { mode: 0o600 });
473
468
  return adminEnvPath;
474
469
  }
475
-
476
- // validateSecrets is imported from ./utils/secrets-helpers
477
-
478
470
  module.exports = {
479
471
  loadSecrets,
480
472
  resolveKvReferences,
@@ -83,7 +83,8 @@ function buildWebappVariables(appName, displayName, config, imageName, imageTag)
83
83
  key: appName,
84
84
  displayName: displayName,
85
85
  description: `${appName.replace(/-/g, ' ')} application`,
86
- type: appType
86
+ type: appType,
87
+ version: config.version || '1.0.0'
87
88
  },
88
89
  image: {
89
90
  name: imageName,