@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,378 @@
1
+ /**
2
+ * AI Fabrix Builder – Ensure secrets in configured store
3
+ *
4
+ * Ensures missing secret keys exist in the correct store (file path, remote API, or
5
+ * user secrets file). New values are encrypted when writing to file and
6
+ * secrets-encryption is set. Remote write tries API first; on failure falls back
7
+ * to user file with a warning.
8
+ *
9
+ * @fileoverview Central ensure-secrets service for zero-touch install
10
+ * @author AI Fabrix Team
11
+ * @version 2.0.0
12
+ */
13
+
14
+ const path = require('path');
15
+ const fs = require('fs');
16
+ const os = require('os');
17
+ const config = require('./config');
18
+ const pathsUtil = require('../utils/paths');
19
+ const logger = require('../utils/logger');
20
+ const { isRemoteSecretsUrl, getRemoteDevAuth } = require('../utils/remote-dev-auth');
21
+ const devApi = require('../api/dev.api');
22
+ const {
23
+ findMissingSecretKeys,
24
+ generateSecretValue,
25
+ loadExistingSecrets,
26
+ appendSecretsToFile,
27
+ saveSecretsFile
28
+ } = require('../utils/secrets-generator');
29
+ const { encryptSecret } = require('../utils/secrets-encryption');
30
+ const { loadEnvTemplate } = require('../utils/secrets-helpers');
31
+
32
+ /**
33
+ * Expand leading ~ to home directory.
34
+ * @param {string} filePath - Path that may start with ~
35
+ * @returns {string} Resolved path
36
+ */
37
+ function expandTilde(filePath) {
38
+ if (!filePath || typeof filePath !== 'string') return filePath;
39
+ if (filePath === '~') return os.homedir();
40
+ if (filePath.startsWith('~/') || filePath.startsWith('~' + path.sep)) {
41
+ return path.join(os.homedir(), filePath.slice(2));
42
+ }
43
+ return filePath;
44
+ }
45
+
46
+ /**
47
+ * Resolve write target from config.
48
+ * - File path → that path (expand ~)
49
+ * - http(s) URL → remote (fallback: user file)
50
+ * - No config → user file
51
+ *
52
+ * @returns {Promise<{ type: 'file'|'remote', filePath?: string, serverUrl?: string, clientCertPem?: string }>}
53
+ */
54
+ async function resolveWriteTarget() {
55
+ const secretsPath = await config.getSecretsPath();
56
+ const userFilePath = path.join(pathsUtil.getAifabrixHome(), 'secrets.local.yaml');
57
+
58
+ if (!secretsPath) {
59
+ return { type: 'file', filePath: userFilePath };
60
+ }
61
+ if (isRemoteSecretsUrl(secretsPath)) {
62
+ const auth = await getRemoteDevAuth();
63
+ return {
64
+ type: 'remote',
65
+ filePath: userFilePath,
66
+ serverUrl: secretsPath.replace(/\/+$/, ''),
67
+ clientCertPem: auth ? auth.clientCertPem : null
68
+ };
69
+ }
70
+ const filePath = path.isAbsolute(secretsPath)
71
+ ? secretsPath
72
+ : path.resolve(process.cwd(), expandTilde(secretsPath));
73
+ return { type: 'file', filePath };
74
+ }
75
+
76
+ /**
77
+ * Load existing secrets from the resolved target (file or remote).
78
+ *
79
+ * @param {{ type: string, filePath?: string, serverUrl?: string, clientCertPem?: string }} target
80
+ * @returns {Promise<Object>} Existing secrets key-value object
81
+ */
82
+ async function loadExistingFromTarget(target) {
83
+ if (target.type === 'file' && target.filePath) {
84
+ return loadExistingSecrets(target.filePath);
85
+ }
86
+ if (target.type === 'remote' && target.serverUrl && target.clientCertPem) {
87
+ try {
88
+ const items = await devApi.listSecrets(target.serverUrl, target.clientCertPem);
89
+ if (!Array.isArray(items)) return {};
90
+ const obj = {};
91
+ for (const item of items) {
92
+ if (item && typeof item.name === 'string' && item.value !== undefined) {
93
+ obj[item.name] = String(item.value);
94
+ }
95
+ }
96
+ return obj;
97
+ } catch {
98
+ return {};
99
+ }
100
+ }
101
+ if (target.type === 'remote' && target.filePath) {
102
+ return loadExistingSecrets(target.filePath);
103
+ }
104
+ return {};
105
+ }
106
+
107
+ /**
108
+ * Write a single secret to file, optionally encrypting the value.
109
+ *
110
+ * @param {string} filePath - Resolved file path
111
+ * @param {string} key - Secret key
112
+ * @param {string} value - Plain value
113
+ * @param {string|null} encryptionKey - Config secrets-encryption key or null
114
+ * @returns {Promise<void>}
115
+ */
116
+ async function writeSecretToFile(filePath, key, value, encryptionKey) {
117
+ const dir = path.dirname(filePath);
118
+ if (!fs.existsSync(dir)) {
119
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
120
+ }
121
+ let valueToWrite = value;
122
+ if (encryptionKey && value !== '') {
123
+ try {
124
+ valueToWrite = encryptSecret(value, encryptionKey);
125
+ } catch {
126
+ // Keep plaintext if encryption fails
127
+ }
128
+ }
129
+ appendSecretsToFile(filePath, { [key]: valueToWrite });
130
+ }
131
+
132
+ /**
133
+ * Compute value for a key (suggested or generated).
134
+ * @param {string} key - Secret key
135
+ * @param {Object} suggested - Map of suggested values
136
+ * @param {boolean} emptyForCredentials - Use empty string if true
137
+ * @returns {string}
138
+ */
139
+ function valueForKey(key, suggested, emptyForCredentials) {
140
+ if (key in suggested) return String(suggested[key]);
141
+ return emptyForCredentials ? '' : generateSecretValue(key);
142
+ }
143
+
144
+ /**
145
+ * Add secrets via remote API; on failure write to local file (with encryption if configured).
146
+ * @param {Object} target - Resolved target (remote)
147
+ * @param {string[]} toAdd - Keys to add
148
+ * @param {Object} suggested - Suggested values
149
+ * @param {string[]} added - Array to push added keys to
150
+ * @param {string|null} encryptionKey - Encryption key for file fallback or null
151
+ * @returns {Promise<string[]>}
152
+ */
153
+ async function addSecretsRemote(target, toAdd, suggested, added, encryptionKey) {
154
+ const emptyForCredentials = false;
155
+ for (const key of toAdd) {
156
+ const value = valueForKey(key, suggested, emptyForCredentials);
157
+ try {
158
+ await devApi.addSecret(target.serverUrl, target.clientCertPem, { key, value });
159
+ added.push(key);
160
+ } catch (err) {
161
+ logger.warn(`Remote secret "${key}" failed (${err.message}); writing to local file.`);
162
+ await writeSecretToFile(target.filePath, key, value, encryptionKey);
163
+ added.push(key);
164
+ }
165
+ }
166
+ return added;
167
+ }
168
+
169
+ /**
170
+ * Add secrets to file (with optional encryption).
171
+ * @param {string} filePath - File path
172
+ * @param {string[]} toAdd - Keys to add
173
+ * @param {Object} suggested - Suggested values
174
+ * @param {boolean} emptyForCredentials - Use empty for new values
175
+ * @param {string|null} encryptionKey - Encryption key or null
176
+ * @param {string[]} added - Array to push added keys to
177
+ * @returns {Promise<string[]>}
178
+ */
179
+ async function addSecretsToFile(filePath, toAdd, suggested, emptyForCredentials, encryptionKey, added) {
180
+ for (const key of toAdd) {
181
+ const value = valueForKey(key, suggested, emptyForCredentials);
182
+ await writeSecretToFile(filePath, key, value, encryptionKey);
183
+ added.push(key);
184
+ }
185
+ if (added.length > 0) {
186
+ logger.log(`✓ Ensured ${added.length} secret key(s): ${added.join(', ')}`);
187
+ }
188
+ return added;
189
+ }
190
+
191
+ /**
192
+ * Ensure a list of secret keys exists in the configured store.
193
+ * Only adds keys that are missing or empty. Uses generateSecretValue for new
194
+ * values unless emptyValuesForCredentials is true (then empty string).
195
+ *
196
+ * @async
197
+ * @function ensureSecretsForKeys
198
+ * @param {string[]} keys - Secret keys to ensure
199
+ * @param {Object} [options] - Options
200
+ * @param {boolean} [options.emptyValuesForCredentials=false] - Use empty string for new values
201
+ * @param {Object} [options.suggestedValues] - Optional map key -> value for specific keys
202
+ * @returns {Promise<string[]>} Keys that were added (new or backfilled)
203
+ * @throws {Error} If config or file write fails
204
+ */
205
+ async function ensureSecretsForKeys(keys, options = {}) {
206
+ if (!Array.isArray(keys) || keys.length === 0) {
207
+ return [];
208
+ }
209
+ const emptyForCredentials = Boolean(options.emptyValuesForCredentials);
210
+ const suggested = options.suggestedValues && typeof options.suggestedValues === 'object'
211
+ ? options.suggestedValues
212
+ : {};
213
+
214
+ const target = options._targetOverride || await resolveWriteTarget();
215
+ const existing = await loadExistingFromTarget(target);
216
+ const toAdd = keys.filter((k) => {
217
+ const v = existing[k];
218
+ const missingOrEmpty = v === undefined || v === null || (typeof v === 'string' && v.trim() === '');
219
+ return missingOrEmpty && !KEYS_ALLOWED_EMPTY.has(k);
220
+ });
221
+ if (toAdd.length === 0) return [];
222
+
223
+ const encryptionKey = await config.getSecretsEncryptionKey();
224
+ const added = [];
225
+
226
+ if (target.type === 'remote' && target.serverUrl && target.clientCertPem) {
227
+ return addSecretsRemote(target, toAdd, suggested, added, encryptionKey);
228
+ }
229
+ return addSecretsToFile(target.filePath, toAdd, suggested, emptyForCredentials, encryptionKey, added);
230
+ }
231
+
232
+ /**
233
+ * Ensure secrets referenced in an env template exist in the configured store.
234
+ * Reads template from path or uses content if content is provided (string with kv://).
235
+ *
236
+ * @async
237
+ * @function ensureSecretsFromEnvTemplate
238
+ * @param {string} envTemplatePathOrContent - Path to env.template or template content
239
+ * @param {Object} [options] - Options
240
+ * @param {boolean} [options.emptyValuesForCredentials=false] - Use empty string for new values
241
+ * @returns {Promise<string[]>} Keys that were added
242
+ * @throws {Error} If template cannot be read or ensure fails
243
+ */
244
+ async function ensureSecretsFromEnvTemplate(envTemplatePathOrContent, options = {}) {
245
+ let template;
246
+ const input = typeof envTemplatePathOrContent === 'string' ? envTemplatePathOrContent : '';
247
+ const looksLikePath = input.length > 0 && !input.includes('\n') && !input.includes('kv://');
248
+ if (looksLikePath && fs.existsSync(input)) {
249
+ template = loadEnvTemplate(input);
250
+ } else if (input.includes('kv://')) {
251
+ template = input;
252
+ } else if (looksLikePath) {
253
+ const err = new Error(`env.template not found: ${input}`);
254
+ err.code = 'ENOENT';
255
+ throw err;
256
+ } else {
257
+ throw new Error('env.template path or content is required');
258
+ }
259
+ let target;
260
+ if (options.preferredFilePath && typeof options.preferredFilePath === 'string') {
261
+ const filePath = path.isAbsolute(options.preferredFilePath)
262
+ ? options.preferredFilePath
263
+ : path.resolve(process.cwd(), options.preferredFilePath);
264
+ target = { type: 'file', filePath };
265
+ } else {
266
+ target = await resolveWriteTarget();
267
+ }
268
+ const existing = await loadExistingFromTarget(target);
269
+ const missingKeys = findMissingSecretKeys(template, existing);
270
+ return ensureSecretsForKeys(missingKeys, { ...options, _targetOverride: target });
271
+ }
272
+
273
+ /**
274
+ * Infra secret keys used by createDefaultSecrets / keyvault.md for up-infra.
275
+ * Includes miso-controller DB keys so ensureMisoInitScript can read from store.
276
+ *
277
+ * postgres-passwordKeyVault: Postgres superuser/admin password for local Docker Postgres,
278
+ * PgAdmin, and Redis Commander (see generateAdminSecretsEnv in lib/core/secrets.js).
279
+ *
280
+ * @type {string[]}
281
+ */
282
+ const INFRA_SECRET_KEYS = [
283
+ 'postgres-passwordKeyVault',
284
+ 'redis-passwordKeyVault',
285
+ 'redis-url',
286
+ 'keycloak-admin-passwordKeyVault',
287
+ 'keycloak-server-url',
288
+ 'databases-miso-controller-0-passwordKeyVault',
289
+ 'databases-miso-controller-0-urlKeyVault'
290
+ ];
291
+
292
+ /**
293
+ * Keys that are valid when empty (e.g. no Redis password in local dev).
294
+ * We do not backfill these when empty, so the file is not appended with a duplicate key.
295
+ *
296
+ * @type {Set<string>}
297
+ */
298
+ const KEYS_ALLOWED_EMPTY = new Set(['redis-passwordKeyVault']);
299
+
300
+ /**
301
+ * Write one key-value to a file store (load, merge, optionally encrypt, save).
302
+ * @param {string} filePath - Secrets file path
303
+ * @param {string} key - Secret key
304
+ * @param {string} strValue - Plain value
305
+ * @returns {Promise<void>}
306
+ */
307
+ async function writeSecretToStoreFile(filePath, key, strValue) {
308
+ const existing = loadExistingSecrets(filePath);
309
+ const encryptionKey = await config.getSecretsEncryptionKey();
310
+ let valueToWrite = strValue;
311
+ if (encryptionKey && strValue !== '') {
312
+ try {
313
+ valueToWrite = encryptSecret(strValue, encryptionKey);
314
+ } catch {
315
+ // Keep plaintext if encryption fails
316
+ }
317
+ }
318
+ existing[key] = valueToWrite;
319
+ const dir = path.dirname(filePath);
320
+ if (!fs.existsSync(dir)) {
321
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
322
+ }
323
+ saveSecretsFile(filePath, existing);
324
+ }
325
+
326
+ /**
327
+ * Write a single secret to the configured store (overwrites if key exists).
328
+ * Used when syncing e.g. postgres-passwordKeyVault after --adminPwd override.
329
+ *
330
+ * @async
331
+ * @function setSecretInStore
332
+ * @param {string} key - Secret key
333
+ * @param {string} value - Plain value
334
+ * @returns {Promise<void>}
335
+ */
336
+ async function setSecretInStore(key, value) {
337
+ if (!key || typeof key !== 'string' || value === undefined) return;
338
+ const target = await resolveWriteTarget();
339
+ const strValue = typeof value === 'string' ? value : String(value);
340
+ if (target.type === 'remote' && target.serverUrl && target.clientCertPem) {
341
+ try {
342
+ await devApi.addSecret(target.serverUrl, target.clientCertPem, { key, value: strValue });
343
+ } catch (err) {
344
+ logger.warn(`Could not sync secret "${key}" to remote store: ${err.message}`);
345
+ const encryptionKey = await config.getSecretsEncryptionKey();
346
+ await writeSecretToFile(target.filePath, key, strValue, encryptionKey);
347
+ }
348
+ return;
349
+ }
350
+ await writeSecretToStoreFile(target.filePath, key, strValue);
351
+ }
352
+
353
+ /**
354
+ * Ensure infra secrets exist in the configured store. Call before ensureAdminSecrets or startInfra.
355
+ *
356
+ * @async
357
+ * @function ensureInfraSecrets
358
+ * @param {Object} [options] - Options
359
+ * @param {string} [options.adminPwd] - Override for postgres-passwordKeyVault when creating new secrets
360
+ * @returns {Promise<string[]>} Keys that were added
361
+ */
362
+ async function ensureInfraSecrets(options = {}) {
363
+ const suggested = {};
364
+ if (options.adminPwd && typeof options.adminPwd === 'string' && options.adminPwd.trim() !== '') {
365
+ suggested['postgres-passwordKeyVault'] = options.adminPwd.trim();
366
+ }
367
+ return ensureSecretsForKeys(INFRA_SECRET_KEYS, { suggestedValues: suggested });
368
+ }
369
+
370
+ module.exports = {
371
+ ensureSecretsForKeys,
372
+ ensureSecretsFromEnvTemplate,
373
+ ensureInfraSecrets,
374
+ setSecretInStore,
375
+ resolveWriteTarget,
376
+ loadExistingFromTarget,
377
+ INFRA_SECRET_KEYS
378
+ };
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Resolve .env in memory and write only to envOutputPath or temp (no builder/ or integration/).
3
+ *
4
+ * @fileoverview Single .env write for run flow (plan 66)
5
+ * @author AI Fabrix Team
6
+ * @version 2.0.0
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const os = require('os');
14
+
15
+ /**
16
+ * Check if env content has a non-empty value for a given key (KEY=value line).
17
+ * Returns false when value is empty or an unresolved kv:// reference.
18
+ * @param {string} content - .env-style content
19
+ * @param {string} key - Variable name (e.g. NPM_TOKEN)
20
+ * @returns {boolean} True if key exists with a real value (non-empty, not kv://)
21
+ */
22
+ function envContentHasKey(content, key) {
23
+ if (!content || typeof content !== 'string') return false;
24
+ const re = new RegExp(`^${key}=(.+)$`, 'm');
25
+ const m = content.match(re);
26
+ if (!m || !m[1]) return false;
27
+ const val = String(m[1]).trim();
28
+ return val.length > 0 && !val.startsWith('kv://');
29
+ }
30
+
31
+ /**
32
+ * Get secret value trying common key variants (e.g. npm_token, NPM_TOKEN, npm-token).
33
+ * @param {Object} secrets - Loaded secrets object
34
+ * @param {string} preferred - Preferred key (e.g. 'NPM_TOKEN')
35
+ * @param {string} alternate - Alternate key (e.g. 'npm_token')
36
+ * @returns {string|null} Value or null
37
+ */
38
+ function getSecretForEnvVar(secrets, preferred, alternate) {
39
+ if (!secrets || typeof secrets !== 'object') return null;
40
+ const keys = [preferred, alternate];
41
+ if (alternate.includes('_')) keys.push(alternate.replace('_', '-'));
42
+ for (const k of keys) {
43
+ const v = secrets[k];
44
+ if (v !== null && v !== undefined && String(v).trim() !== '') return String(v).trim();
45
+ }
46
+ return null;
47
+ }
48
+
49
+ /**
50
+ * Inject NPM_TOKEN and PYPI_TOKEN from loaded secrets into content when missing.
51
+ * @param {string} content - .env-style content
52
+ * @param {string|null} secretsPath - Path to secrets file
53
+ * @param {string} appName - Application name
54
+ * @returns {Promise<string>} Content with tokens injected when possible
55
+ */
56
+ async function injectRegistryTokens(content, secretsPath, appName) {
57
+ const secrets = require('./secrets');
58
+ try {
59
+ const loadedSecrets = await secrets.loadSecrets(secretsPath, appName);
60
+ let out = content || '';
61
+ if (!envContentHasKey(out, 'NPM_TOKEN')) {
62
+ const v = getSecretForEnvVar(loadedSecrets, 'NPM_TOKEN', 'npm_token');
63
+ if (v) out = out.trimEnd() + (out.endsWith('\n') ? '' : '\n') + `NPM_TOKEN=${v}\n`;
64
+ }
65
+ if (!envContentHasKey(out, 'PYPI_TOKEN')) {
66
+ const v = getSecretForEnvVar(loadedSecrets, 'PYPI_TOKEN', 'pypi_token');
67
+ if (v) out = out.trimEnd() + (out.endsWith('\n') ? '' : '\n') + `PYPI_TOKEN=${v}\n`;
68
+ }
69
+ return out;
70
+ } catch {
71
+ return content || '';
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Parse .env-style content into a key-value map (excludes comments and empty lines).
77
+ * @param {string} content - .env-style content
78
+ * @returns {Object.<string, string>} Map of variable name to value
79
+ */
80
+ function parseEnvContentToMap(content) {
81
+ const map = {};
82
+ if (!content || typeof content !== 'string') return map;
83
+ const lines = content.split(/\r?\n/);
84
+ for (const line of lines) {
85
+ const trimmed = line.trim();
86
+ if (!trimmed || trimmed.startsWith('#')) continue;
87
+ const eq = trimmed.indexOf('=');
88
+ if (eq > 0) {
89
+ const key = trimmed.substring(0, eq).trim();
90
+ map[key] = trimmed.substring(eq + 1);
91
+ }
92
+ }
93
+ return map;
94
+ }
95
+
96
+ /**
97
+ * Resolve .env in memory and write only to envOutputPath or temp (no builder/ or integration/).
98
+ * Injects NPM_TOKEN and PYPI_TOKEN from secrets when missing so shell/install/test have registry tokens.
99
+ *
100
+ * @async
101
+ * @function resolveAndWriteEnvFile
102
+ * @param {string} appName - Application name
103
+ * @param {Object} options - Options
104
+ * @param {string|null} [options.envOutputPath] - Absolute path to write .env (when set)
105
+ * @param {string} [options.environment='docker'] - Environment context ('local' or 'docker')
106
+ * @param {string|null} [options.secretsPath] - Path to secrets file (optional)
107
+ * @param {boolean} [options.force=false] - Generate missing secret keys
108
+ * @returns {Promise<string>} Path where .env was written (envOutputPath or temp file)
109
+ * @throws {Error} If generation fails
110
+ */
111
+ async function resolveAndWriteEnvFile(appName, options = {}) {
112
+ const secrets = require('./secrets');
113
+ const envOutputPath = options.envOutputPath || null;
114
+ const environment = options.environment || 'docker';
115
+ const secretsPath = options.secretsPath || null;
116
+ const force = options.force === true;
117
+
118
+ let resolved = await secrets.generateEnvContent(appName, secretsPath, environment, force);
119
+ resolved = await injectRegistryTokens(resolved, secretsPath, appName);
120
+
121
+ if (envOutputPath && typeof envOutputPath === 'string') {
122
+ const dir = path.dirname(envOutputPath);
123
+ if (!fs.existsSync(dir)) {
124
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
125
+ }
126
+ fs.writeFileSync(envOutputPath, resolved, { mode: 0o600 });
127
+ return envOutputPath;
128
+ }
129
+
130
+ const tmpDir = os.tmpdir();
131
+ const tmpPath = path.join(tmpDir, `aifabrix-${appName}-${Date.now()}.env`);
132
+ fs.writeFileSync(tmpPath, resolved, { mode: 0o600 });
133
+ return tmpPath;
134
+ }
135
+
136
+ /**
137
+ * Resolve app env (template + kv:// secrets) and return as key-value map.
138
+ * Used by build to pass NPM_TOKEN/PYPI_TOKEN as Docker build-args.
139
+ * Injects NPM_TOKEN/PYPI_TOKEN from secrets when missing (same as resolveAndWriteEnvFile).
140
+ *
141
+ * @async
142
+ * @function resolveAndGetEnvMap
143
+ * @param {string} appName - Application name
144
+ * @param {Object} [options] - Options (same as resolveAndWriteEnvFile)
145
+ * @returns {Promise<Object.<string, string>>} Map of variable name to value
146
+ */
147
+ async function resolveAndGetEnvMap(appName, options = {}) {
148
+ const secrets = require('./secrets');
149
+ const environment = options.environment || 'docker';
150
+ const secretsPath = options.secretsPath || null;
151
+ const force = options.force === true;
152
+ let content = await secrets.generateEnvContent(appName, secretsPath, environment, force);
153
+ content = await injectRegistryTokens(content, secretsPath, appName);
154
+ return parseEnvContentToMap(content);
155
+ }
156
+
157
+ module.exports = { resolveAndWriteEnvFile, resolveAndGetEnvMap };