@aifabrix/builder 2.40.0 → 2.41.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 (108) hide show
  1. package/README.md +7 -5
  2. package/integration/hubspot/test.js +1 -1
  3. package/jest.config.manual.js +29 -0
  4. package/lib/api/credential.api.js +40 -0
  5. package/lib/api/dev.api.js +423 -0
  6. package/lib/api/types/credential.types.js +23 -0
  7. package/lib/api/types/dev.types.js +140 -0
  8. package/lib/app/config.js +21 -0
  9. package/lib/app/down.js +2 -1
  10. package/lib/app/index.js +9 -0
  11. package/lib/app/push.js +36 -12
  12. package/lib/app/readme.js +1 -3
  13. package/lib/app/run-env-compose.js +201 -0
  14. package/lib/app/run-helpers.js +121 -118
  15. package/lib/app/run.js +148 -28
  16. package/lib/app/show.js +5 -2
  17. package/lib/build/index.js +11 -3
  18. package/lib/cli/setup-app.js +140 -14
  19. package/lib/cli/setup-auth.js +1 -0
  20. package/lib/cli/setup-dev.js +180 -17
  21. package/lib/cli/setup-environment.js +4 -2
  22. package/lib/cli/setup-external-system.js +71 -21
  23. package/lib/cli/setup-infra.js +29 -2
  24. package/lib/cli/setup-secrets.js +52 -5
  25. package/lib/cli/setup-utility.js +19 -4
  26. package/lib/commands/app-install.js +172 -0
  27. package/lib/commands/app-shell.js +75 -0
  28. package/lib/commands/app-test.js +282 -0
  29. package/lib/commands/app.js +1 -1
  30. package/lib/commands/auth-status.js +36 -3
  31. package/lib/commands/dev-cli-handlers.js +141 -0
  32. package/lib/commands/dev-down.js +114 -0
  33. package/lib/commands/dev-init.js +309 -0
  34. package/lib/commands/secrets-list.js +118 -0
  35. package/lib/commands/secrets-remove.js +97 -0
  36. package/lib/commands/secrets-set.js +30 -17
  37. package/lib/commands/secrets-validate.js +50 -0
  38. package/lib/commands/up-dataplane.js +2 -2
  39. package/lib/commands/up-miso.js +0 -25
  40. package/lib/commands/upload.js +26 -1
  41. package/lib/core/admin-secrets.js +96 -0
  42. package/lib/core/secrets-ensure.js +378 -0
  43. package/lib/core/secrets-env-write.js +157 -0
  44. package/lib/core/secrets.js +147 -81
  45. package/lib/datasource/field-reference-validator.js +91 -0
  46. package/lib/datasource/validate.js +21 -3
  47. package/lib/deployment/environment-config.js +137 -0
  48. package/lib/deployment/environment.js +21 -98
  49. package/lib/deployment/push.js +32 -2
  50. package/lib/external-system/download.js +7 -0
  51. package/lib/external-system/test-auth.js +7 -3
  52. package/lib/external-system/test.js +5 -1
  53. package/lib/generator/index.js +174 -25
  54. package/lib/generator/wizard.js +13 -1
  55. package/lib/infrastructure/helpers.js +103 -20
  56. package/lib/infrastructure/index.js +88 -10
  57. package/lib/infrastructure/services.js +70 -15
  58. package/lib/schema/application-schema.json +24 -3
  59. package/lib/schema/external-system.schema.json +435 -413
  60. package/lib/utils/api.js +3 -3
  61. package/lib/utils/app-register-auth.js +25 -3
  62. package/lib/utils/cli-utils.js +20 -0
  63. package/lib/utils/compose-generator.js +76 -75
  64. package/lib/utils/compose-handlebars-helpers.js +43 -0
  65. package/lib/utils/compose-vector-helper.js +18 -0
  66. package/lib/utils/config-paths.js +127 -2
  67. package/lib/utils/credential-secrets-env.js +267 -0
  68. package/lib/utils/dev-cert-helper.js +122 -0
  69. package/lib/utils/device-code-helpers.js +224 -0
  70. package/lib/utils/device-code.js +37 -336
  71. package/lib/utils/docker-build.js +40 -8
  72. package/lib/utils/env-copy.js +83 -13
  73. package/lib/utils/env-map.js +35 -5
  74. package/lib/utils/env-template.js +6 -5
  75. package/lib/utils/error-formatters/http-status-errors.js +20 -1
  76. package/lib/utils/help-builder.js +15 -2
  77. package/lib/utils/infra-status.js +30 -1
  78. package/lib/utils/local-secrets.js +7 -52
  79. package/lib/utils/mutagen-install.js +195 -0
  80. package/lib/utils/mutagen.js +146 -0
  81. package/lib/utils/paths.js +49 -33
  82. package/lib/utils/port-resolver.js +28 -16
  83. package/lib/utils/remote-dev-auth.js +38 -0
  84. package/lib/utils/remote-docker-env.js +43 -0
  85. package/lib/utils/remote-secrets-loader.js +60 -0
  86. package/lib/utils/secrets-generator.js +94 -6
  87. package/lib/utils/secrets-helpers.js +33 -25
  88. package/lib/utils/secrets-path.js +2 -2
  89. package/lib/utils/secrets-utils.js +52 -1
  90. package/lib/utils/secrets-validation.js +84 -0
  91. package/lib/utils/ssh-key-helper.js +116 -0
  92. package/lib/utils/token-manager-messages.js +90 -0
  93. package/lib/utils/token-manager.js +5 -4
  94. package/lib/utils/variable-transformer.js +3 -3
  95. package/lib/validation/validate.js +1 -1
  96. package/lib/validation/validator.js +65 -0
  97. package/package.json +4 -2
  98. package/scripts/install-local.js +34 -15
  99. package/templates/README.md +0 -1
  100. package/templates/applications/README.md.hbs +4 -4
  101. package/templates/applications/dataplane/application.yaml +5 -4
  102. package/templates/applications/dataplane/env.template +12 -7
  103. package/templates/applications/keycloak/env.template +2 -0
  104. package/templates/applications/miso-controller/application.yaml +1 -0
  105. package/templates/applications/miso-controller/env.template +11 -9
  106. package/templates/external-system/external-system.json.hbs +1 -16
  107. package/templates/python/docker-compose.hbs +49 -23
  108. package/templates/typescript/docker-compose.hbs +48 -22
@@ -0,0 +1,140 @@
1
+ /**
2
+ * @fileoverview Builder Server (dev) API type definitions – issue-cert, settings, users, SSH keys, secrets
3
+ * @author AI Fabrix Team
4
+ * @version 2.0.0
5
+ */
6
+
7
+ /**
8
+ * Issue certificate request (POST /api/dev/issue-cert). Public; no client cert.
9
+ * @typedef {Object} IssueCertDto
10
+ * @property {string} developerId - Developer ID (must match user for whom PIN was created)
11
+ * @property {string} pin - One-time PIN from POST /api/dev/users/:id/pin
12
+ * @property {string} csr - PEM-encoded Certificate Signing Request
13
+ */
14
+
15
+ /**
16
+ * Issue certificate response (POST /api/dev/issue-cert)
17
+ * @typedef {Object} IssueCertResponseDto
18
+ * @property {string} certificate - PEM-encoded X.509 certificate
19
+ * @property {number} validDays - Validity in days
20
+ * @property {string} validNotAfter - ISO 8601 validity end (UTC)
21
+ * @property {string} [caCertificate] - Optional PEM-encoded CA certificate (for remote Docker TLS; saved as ca.pem)
22
+ * @property {string} [ca] - Optional alias for caCertificate
23
+ */
24
+
25
+ /**
26
+ * Developer settings (GET /api/dev/settings). Cert-authenticated.
27
+ * @typedef {Object} SettingsResponseDto
28
+ * @property {string} user-mutagen-folder - Server path to workspace root (no app segment)
29
+ * @property {string} secrets-encryption - Encryption key (hex)
30
+ * @property {string} aifabrix-secrets - Path or URL for secrets
31
+ * @property {string} aifabrix-env-config - Env config path
32
+ * @property {string} remote-server - Builder-server base URL
33
+ * @property {string} docker-endpoint - Docker API endpoint
34
+ * @property {string} sync-ssh-user - SSH user for Mutagen
35
+ * @property {string} sync-ssh-host - SSH host for Mutagen
36
+ */
37
+
38
+ /**
39
+ * User list item (GET /api/dev/users)
40
+ * @typedef {Object} UserResponseDto
41
+ * @property {string} id - Developer ID
42
+ * @property {string} name - Display name
43
+ * @property {string} email - Email
44
+ * @property {string} createdAt - ISO 8601
45
+ * @property {boolean} certificateIssued - Whether cert was issued
46
+ * @property {string} [certificateValidNotAfter] - Cert validity end (optional)
47
+ * @property {string[]} groups - Access groups (admin, secret-manager, developer)
48
+ */
49
+
50
+ /**
51
+ * Create user request (POST /api/dev/users)
52
+ * @typedef {Object} CreateUserDto
53
+ * @property {string} developerId - Unique developer ID (numeric string)
54
+ * @property {string} name - Display name
55
+ * @property {string} email - Email
56
+ * @property {string[]} [groups] - Default [developer]
57
+ */
58
+
59
+ /**
60
+ * Update user request (PATCH /api/dev/users/:id). At least one field.
61
+ * @typedef {Object} UpdateUserDto
62
+ * @property {string} [name] - Display name
63
+ * @property {string} [email] - Email
64
+ * @property {string[]} [groups] - Access groups
65
+ */
66
+
67
+ /**
68
+ * Create PIN response (POST /api/dev/users/:id/pin)
69
+ * @typedef {Object} CreatePinResponseDto
70
+ * @property {string} pin - One-time PIN
71
+ * @property {string} expiresAt - ISO 8601
72
+ */
73
+
74
+ /**
75
+ * Add SSH key request (POST /api/dev/users/:id/ssh-keys)
76
+ * @typedef {Object} AddSshKeyDto
77
+ * @property {string} publicKey - SSH public key line
78
+ * @property {string} [label] - Optional label
79
+ */
80
+
81
+ /**
82
+ * SSH key item (list/add response)
83
+ * @typedef {Object} SshKeyItemDto
84
+ * @property {string} fingerprint - Key fingerprint
85
+ * @property {string} [label] - Optional label
86
+ * @property {string} [createdAt] - ISO 8601
87
+ */
88
+
89
+ /**
90
+ * Deleted response (DELETE endpoints)
91
+ * @typedef {Object} DeletedResponseDto
92
+ * @property {string} deleted - ID or key of deleted resource
93
+ */
94
+
95
+ /**
96
+ * Secret item (GET /api/dev/secrets)
97
+ * @typedef {Object} SecretItemDto
98
+ * @property {string} name - Secret key
99
+ * @property {string} value - Decrypted value
100
+ */
101
+
102
+ /**
103
+ * Add secret request (POST /api/dev/secrets)
104
+ * @typedef {Object} AddSecretDto
105
+ * @property {string} key - Secret key
106
+ * @property {string} value - Secret value
107
+ */
108
+
109
+ /**
110
+ * Add secret response
111
+ * @typedef {Object} AddSecretResponseDto
112
+ * @property {string} key - Key that was added/updated
113
+ */
114
+
115
+ /**
116
+ * Delete secret response
117
+ * @typedef {Object} DeleteSecretResponseDto
118
+ * @property {string} deleted - Key that was removed
119
+ */
120
+
121
+ /**
122
+ * Health response (GET /health)
123
+ * @typedef {Object} HealthResponseDto
124
+ * @property {string} status - Overall status, e.g. "ok"
125
+ * @property {Object} checks - Per-component health checks
126
+ * @property {string} checks.dataDir - Data directory check ("ok" or error)
127
+ * @property {string} checks.encryptionKey - Encryption key check ("ok" or error)
128
+ * @property {string} checks.ca - CA certificate check ("ok" or error)
129
+ * @property {string} checks.users - Users store check ("ok" or error)
130
+ * @property {string} checks.tokens - Tokens store check ("ok" or error)
131
+ */
132
+
133
+ /**
134
+ * Error response (all error responses)
135
+ * @typedef {Object} ErrorResponseDto
136
+ * @property {number} statusCode - HTTP status
137
+ * @property {string} error - Short error type
138
+ * @property {string} message - Human-readable message
139
+ * @property {string} [code] - Optional machine-readable code
140
+ */
package/lib/app/config.js CHANGED
@@ -31,6 +31,26 @@ async function fileExists(filePath) {
31
31
  }
32
32
  }
33
33
 
34
+ /**
35
+ * Renames legacy variables.yaml to application.yaml if only variables.yaml exists.
36
+ * Ensures create always results in application.yaml.
37
+ * @async
38
+ * @param {string} appPath - Path to application directory
39
+ */
40
+ async function normalizeLegacyVariablesYaml(appPath) {
41
+ const applicationYaml = path.join(appPath, 'application.yaml');
42
+ const applicationYml = path.join(appPath, 'application.yml');
43
+ const applicationJson = path.join(appPath, 'application.json');
44
+ const variablesYaml = path.join(appPath, 'variables.yaml');
45
+ const hasAppYaml = await fileExists(applicationYaml);
46
+ const hasAppYml = await fileExists(applicationYml);
47
+ const hasAppJson = await fileExists(applicationJson);
48
+ const hasVariables = await fileExists(variablesYaml);
49
+ if (hasVariables && !hasAppYaml && !hasAppYml && !hasAppJson) {
50
+ await fs.rename(variablesYaml, applicationYaml);
51
+ }
52
+ }
53
+
34
54
  /**
35
55
  * Generates application.yaml file if no application config exists
36
56
  * @async
@@ -39,6 +59,7 @@ async function fileExists(filePath) {
39
59
  * @param {Object} config - Application configuration
40
60
  */
41
61
  async function generateVariablesYamlFile(appPath, appName, config) {
62
+ await normalizeLegacyVariablesYaml(appPath);
42
63
  const { resolveApplicationConfigPath } = require('../utils/app-config-resolver');
43
64
  try {
44
65
  resolveApplicationConfigPath(appPath);
package/lib/app/down.js CHANGED
@@ -118,6 +118,7 @@ async function downApp(appName, options = {}) {
118
118
  }
119
119
 
120
120
  module.exports = {
121
- downApp
121
+ downApp,
122
+ getAppVolumeName
122
123
  };
123
124
 
package/lib/app/index.js CHANGED
@@ -31,6 +31,8 @@ const {
31
31
  processTemplateFiles,
32
32
  setupAppFiles
33
33
  } = require('./helpers');
34
+ const path = require('path');
35
+ const secretsEnsure = require('../core/secrets-ensure');
34
36
 
35
37
  /**
36
38
  * Creates new application with scaffolded configuration files
@@ -130,6 +132,13 @@ async function generateApplicationFiles(finalAppPath, appName, config, options)
130
132
 
131
133
  await generateConfigFiles(finalAppPath, appName, config, existingEnv);
132
134
 
135
+ const envTemplatePath = path.join(finalAppPath, 'env.template');
136
+ try {
137
+ await secretsEnsure.ensureSecretsFromEnvTemplate(envTemplatePath, {});
138
+ } catch (err) {
139
+ if (err.code !== 'ENOENT') throw err;
140
+ }
141
+
133
142
  // Generate external system files if type is external
134
143
  if (config.type === 'external') {
135
144
  const externalGenerator = require('../external-system/generator');
package/lib/app/push.js CHANGED
@@ -40,21 +40,38 @@ function validateAppName(appName) {
40
40
  }
41
41
 
42
42
  /**
43
- * Extracts image name from configuration using the same logic as build command
44
- * @param {Object} config - Configuration object from application.yaml
45
- * @param {string} appName - Application name (fallback)
46
- * @returns {string} Image name
43
+ * Returns effective config (unwrap variables wrapper if present so image/app are at top level).
44
+ * application.yaml may have top-level image/app or a variables: { image, app } wrapper.
45
+ * @param {Object} config - Raw config from loadConfigFile
46
+ * @returns {Object} Config with image and app at top level
47
+ */
48
+ function getEffectiveConfig(config) {
49
+ if (!config || typeof config !== 'object') return config || {};
50
+ if (config.variables && typeof config.variables === 'object' && (config.variables.image !== undefined || config.variables.app !== undefined)) {
51
+ return config.variables;
52
+ }
53
+ return config;
54
+ }
55
+
56
+ /**
57
+ * Extracts image name from configuration using the same logic as build command.
58
+ * Uses image.name (e.g. aifabrix/dataplane) so ACR repository matches application.yaml.
59
+ * @param {Object} config - Configuration object from application.yaml (or effective config)
60
+ * @param {string} appName - Application name (fallback when image not set)
61
+ * @returns {string} Image name (e.g. aifabrix/dataplane, not just dataplane)
47
62
  */
48
63
  function extractImageName(config, appName) {
49
- if (typeof config.image === 'string') {
50
- return config.image.split(':')[0];
51
- } else if (config.image?.name) {
52
- return config.image.name;
53
- } else if (config.app?.key) {
54
- return config.app.key;
64
+ const c = getEffectiveConfig(config);
65
+ if (typeof c.image === 'string') {
66
+ return c.image.split(':')[0];
67
+ }
68
+ if (c.image?.name) {
69
+ return c.image.name;
70
+ }
71
+ if (c.app?.key) {
72
+ return c.app.key;
55
73
  }
56
74
  return appName;
57
-
58
75
  }
59
76
 
60
77
  /**
@@ -72,7 +89,8 @@ async function loadPushConfig(appName, options) {
72
89
  try {
73
90
  const configPath = resolveApplicationConfigPath(appPath);
74
91
  const config = loadConfigFile(configPath);
75
- const registry = options.registry || config.image?.registry;
92
+ const effective = getEffectiveConfig(config);
93
+ const registry = options.registry || effective.image?.registry;
76
94
  if (!registry) {
77
95
  throw new Error('Registry URL is required. Provide via --registry flag or configure in application config under image.registry');
78
96
  }
@@ -110,6 +128,12 @@ async function validatePushConfig(registry, imageName, appName) {
110
128
  if (!await pushUtils.checkAzureCLIInstalled()) {
111
129
  throw new Error('Azure CLI is not installed. Install from: https://docs.microsoft.com/cli/azure/install-azure-cli');
112
130
  }
131
+
132
+ if (!await pushUtils.checkAzureLogin()) {
133
+ throw new Error(
134
+ 'Not logged in to Azure. Run "az login" first, then run push again.'
135
+ );
136
+ }
113
137
  }
114
138
 
115
139
  /**
package/lib/app/readme.js CHANGED
@@ -120,9 +120,7 @@ function buildExternalDatasourcePlaceholders(systemKey, datasourceCount) {
120
120
  function buildReadmeContext(appName, config) {
121
121
  const displayName = config.displayName || formatAppDisplayName(appName);
122
122
  const port = config.port ?? 3000;
123
- const localPort = (typeof config.build?.localPort === 'number' && config.build.localPort > 0)
124
- ? config.build.localPort
125
- : port;
123
+ const localPort = port;
126
124
  const imageName = config.image?.name || `aifabrix/${appName}`;
127
125
  // Extract registry from nested structure (config.image.registry) or flattened (config.registry)
128
126
  const registry = config.image?.registry || config.registry || 'myacr.azurecr.io';
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Helpers for application run: clean applications dir, build merged .env, compose safeguard.
3
+ * Keeps run-helpers.js under line limit.
4
+ *
5
+ * @fileoverview Run env and compose helpers
6
+ * @author AI Fabrix Team
7
+ * @version 2.0.0
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const path = require('path');
13
+ const fs = require('fs').promises;
14
+ const fsSync = require('fs');
15
+ const pathsUtil = require('../utils/paths');
16
+ const adminSecrets = require('../core/admin-secrets');
17
+ const secretsEnvWrite = require('../core/secrets-env-write');
18
+ const { getContainerPort } = require('../utils/port-resolver');
19
+
20
+ /**
21
+ * Clean applications directory: remove generated docker-compose.yaml and .env.* files.
22
+ * @param {string|number} developerId - Developer ID
23
+ */
24
+ function cleanApplicationsDir(developerId) {
25
+ const baseDir = pathsUtil.getApplicationsBaseDir(developerId);
26
+ if (!fsSync.existsSync(baseDir)) return;
27
+ const toRemove = [path.join(baseDir, 'docker-compose.yaml')];
28
+ try {
29
+ const entries = fsSync.readdirSync(baseDir);
30
+ for (const name of entries) {
31
+ if (name.startsWith('.env.')) toRemove.push(path.join(baseDir, name));
32
+ }
33
+ } catch {
34
+ // Ignore readdir errors
35
+ }
36
+ for (const filePath of toRemove) {
37
+ try {
38
+ if (fsSync.existsSync(filePath)) fsSync.unlinkSync(filePath);
39
+ } catch {
40
+ // Ignore unlink errors
41
+ }
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Derive PostgreSQL user from database name (same as compose-handlebars-helpers pgUserName).
47
+ * @param {string} dbName - Database name (e.g. keycloak)
48
+ * @returns {string} User name (e.g. keycloak_user)
49
+ */
50
+ function pgUserName(dbName) {
51
+ if (!dbName) return '';
52
+ return `${String(dbName).replace(/-/g, '_')}_user`;
53
+ }
54
+
55
+ /**
56
+ * Inject DB_N_NAME and DB_N_USER from application.yaml databases into env so .env has everything.
57
+ * @param {Object} env - Merged env object (mutated)
58
+ * @param {Object} appConfig - Application config (requires.databases or databases array)
59
+ */
60
+ function injectDatabaseNamesAndUsers(env, appConfig) {
61
+ const databases = appConfig?.requires?.databases || appConfig?.databases;
62
+ if (!Array.isArray(databases) || databases.length === 0) return;
63
+ for (let i = 0; i < databases.length; i++) {
64
+ const db = databases[i];
65
+ const name = db?.name || (appConfig?.app?.key || 'app');
66
+ env[`DB_${i}_NAME`] = name;
67
+ env[`DB_${i}_USER`] = pgUserName(name);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Get the env var name used for PORT in env.template (e.g. PORT=${MISO_PORT} -> MISO_PORT).
73
+ * @param {string} appName - Application name
74
+ * @returns {string|null} Variable name or null if not found
75
+ */
76
+ function getPortVarFromEnvTemplate(appName) {
77
+ const builderPath = pathsUtil.getBuilderPath(appName);
78
+ const templatePath = path.join(builderPath, 'env.template');
79
+ if (!fsSync.existsSync(templatePath)) return null;
80
+ try {
81
+ const content = fsSync.readFileSync(templatePath, 'utf8');
82
+ const m = content.match(/^PORT\s*=\s*\$\{([A-Za-z0-9_]+)\}/m);
83
+ return m ? m[1] : null;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Override PORT and the template's port variable (e.g. MISO_PORT) with container port from application.yaml.
91
+ * Run .env only: when running in Docker, the app listens on the container port (port or build.containerPort), not localPort.
92
+ * For envOutputPath .env (local, not reload) we use localPort instead - see adjustLocalEnvPortsInContent in secrets-helpers.
93
+ * @param {Object} env - Merged env object (mutated)
94
+ * @param {Object} appConfig - Application configuration (port, build.containerPort)
95
+ * @param {string} appName - Application name (to resolve env.template port var)
96
+ */
97
+ function injectContainerPortForRun(env, appConfig, appName) {
98
+ const containerPort = getContainerPort(appConfig, 3000);
99
+ env.PORT = String(containerPort);
100
+ const portVar = getPortVarFromEnvTemplate(appName);
101
+ if (portVar) {
102
+ env[portVar] = String(containerPort);
103
+ }
104
+ }
105
+
106
+ /** Keys that must never be passed to the app container (admin/start-only). */
107
+ const ADMIN_ONLY_KEYS = [
108
+ 'POSTGRES_PASSWORD',
109
+ 'PGADMIN_DEFAULT_EMAIL',
110
+ 'PGADMIN_DEFAULT_PASSWORD',
111
+ 'REDIS_HOST',
112
+ 'REDIS_COMMANDER_USER',
113
+ 'REDIS_COMMANDER_PASSWORD'
114
+ ];
115
+
116
+ /**
117
+ * Build app-only env (merged minus admin secrets). App container must not receive admin passwords.
118
+ * @param {Object} merged - Full merged env
119
+ * @returns {Object} Env object safe for app container
120
+ */
121
+ function buildAppOnlyEnv(merged) {
122
+ const appOnly = {};
123
+ for (const [k, v] of Object.entries(merged)) {
124
+ if (ADMIN_ONLY_KEYS.includes(k)) continue;
125
+ appOnly[k] = v;
126
+ }
127
+ return appOnly;
128
+ }
129
+
130
+ /**
131
+ * Build env for db-init only: POSTGRES_PASSWORD + DB_N_PASSWORD, DB_N_NAME, DB_N_USER. Used only for start, not in app container.
132
+ * @param {Object} merged - Full merged env
133
+ * @returns {Object} Env object for db-init service only
134
+ */
135
+ function buildDbInitOnlyEnv(merged) {
136
+ const dbInit = {};
137
+ if (merged.POSTGRES_PASSWORD !== undefined) {
138
+ dbInit.POSTGRES_PASSWORD = merged.POSTGRES_PASSWORD;
139
+ }
140
+ for (const [k, v] of Object.entries(merged)) {
141
+ if (k.startsWith('DB_') && (k.endsWith('_PASSWORD') || k.endsWith('_NAME') || k.endsWith('_USER'))) {
142
+ dbInit[k] = v;
143
+ }
144
+ }
145
+ return dbInit;
146
+ }
147
+
148
+ /**
149
+ * Build two run env files: .env.run (app-only, no admin secrets) and .env.run.admin (start-only, for db-init).
150
+ * Admin password is never set in the app container; .env.run.admin is used only for start and then deleted.
151
+ * @async
152
+ * @param {string} appName - Application name
153
+ * @param {Object} appConfig - Application configuration
154
+ * @param {string} devDir - Applications directory path
155
+ * @returns {Promise<{ runEnvPath: string, runEnvAdminPath: string }>} Paths to .env.run and .env.run.admin
156
+ */
157
+ async function buildMergedRunEnvAndWrite(appName, appConfig, devDir) {
158
+ const infra = require('../infrastructure');
159
+ const ensureAdminSecretsFn = typeof infra.ensureAdminSecrets === 'function'
160
+ ? infra.ensureAdminSecrets
161
+ : require('../infrastructure/helpers').ensureAdminSecrets;
162
+ await ensureAdminSecretsFn();
163
+ const adminObj = await adminSecrets.readAndDecryptAdminSecrets();
164
+ const appObj = await secretsEnvWrite.resolveAndGetEnvMap(appName, {
165
+ environment: 'docker',
166
+ secretsPath: null,
167
+ force: false
168
+ });
169
+ const merged = { ...adminObj, ...appObj };
170
+ injectDatabaseNamesAndUsers(merged, appConfig);
171
+ injectContainerPortForRun(merged, appConfig, appName);
172
+
173
+ const runEnvPath = path.join(devDir, '.env.run');
174
+ const runEnvAdminPath = path.join(devDir, '.env.run.admin');
175
+
176
+ const appOnly = buildAppOnlyEnv(merged);
177
+ const dbInitOnly = buildDbInitOnlyEnv(merged);
178
+
179
+ await fs.writeFile(runEnvPath, adminSecrets.envObjectToContent(appOnly), { mode: 0o600 });
180
+ await fs.writeFile(runEnvAdminPath, adminSecrets.envObjectToContent(dbInitOnly), { mode: 0o600 });
181
+
182
+ return { runEnvPath, runEnvAdminPath };
183
+ }
184
+
185
+ /**
186
+ * Assert generated compose does not contain password literals in environment (ISO 27K).
187
+ * @param {string} composeContent - Generated docker-compose content
188
+ * @throws {Error} If password keys appear in environment-like assignment
189
+ */
190
+ function assertNoPasswordLiteralsInCompose(composeContent) {
191
+ const badPattern = /\n\s+(-?\s*)(POSTGRES_PASSWORD|DB_\d+_PASSWORD)\s*[:=]/;
192
+ if (badPattern.test(composeContent)) {
193
+ throw new Error('Generated compose must not contain password literals (POSTGRES_PASSWORD, DB_*_PASSWORD). Use env_file only.');
194
+ }
195
+ }
196
+
197
+ module.exports = {
198
+ cleanApplicationsDir,
199
+ buildMergedRunEnvAndWrite,
200
+ assertNoPasswordLiteralsInCompose
201
+ };