@aifabrix/builder 2.44.6 → 2.45.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 (86) hide show
  1. package/.cursor/rules/cli-layout.mdc +7 -3
  2. package/jest.projects.js +56 -0
  3. package/lib/app/helpers.js +3 -3
  4. package/lib/app/index.js +3 -3
  5. package/lib/app/register.js +7 -6
  6. package/lib/app/restart-display.js +52 -21
  7. package/lib/app/rotate-secret.js +7 -6
  8. package/lib/app/run-helpers.js +15 -8
  9. package/lib/app/run.js +57 -9
  10. package/lib/app/show-display.js +7 -0
  11. package/lib/app/show.js +87 -5
  12. package/lib/build/index.js +9 -5
  13. package/lib/cli/infra-guided.js +42 -27
  14. package/lib/cli/installation-log-command.js +73 -0
  15. package/lib/cli/setup-app.js +11 -1
  16. package/lib/cli/setup-auth.js +94 -49
  17. package/lib/cli/setup-infra-up-dataplane-action.js +111 -0
  18. package/lib/cli/setup-infra-up-platform-action.js +131 -0
  19. package/lib/cli/setup-infra.js +60 -119
  20. package/lib/cli/setup-platform.js +1 -1
  21. package/lib/cli/setup-utility-resolve.js +132 -0
  22. package/lib/cli/setup-utility.js +65 -51
  23. package/lib/commands/app-logs.js +81 -33
  24. package/lib/commands/auth-config.js +116 -18
  25. package/lib/commands/setup-modes.js +19 -6
  26. package/lib/commands/setup-prompts.js +41 -8
  27. package/lib/commands/setup.js +114 -9
  28. package/lib/commands/teardown.js +54 -5
  29. package/lib/commands/up-common.js +48 -14
  30. package/lib/commands/up-dataplane.js +21 -18
  31. package/lib/commands/up-miso.js +12 -8
  32. package/lib/commands/upload.js +5 -3
  33. package/lib/core/audit-logger.js +1 -34
  34. package/lib/core/config-admin-email.js +56 -0
  35. package/lib/core/config-normalize.js +60 -0
  36. package/lib/core/config-registered-controller-urls.js +54 -0
  37. package/lib/core/config.js +33 -50
  38. package/lib/core/secrets-ensure-infra.js +1 -1
  39. package/lib/core/secrets-env-content.js +86 -90
  40. package/lib/core/secrets-env-declarative-expand.js +170 -0
  41. package/lib/core/secrets-env-write.js +2 -0
  42. package/lib/core/secrets-load.js +106 -102
  43. package/lib/external-system/deploy.js +5 -1
  44. package/lib/internal/node-fs.js +2 -0
  45. package/lib/schema/application-schema.json +4 -0
  46. package/lib/schema/infra.parameter.yaml +10 -0
  47. package/lib/utils/app-config-resolver.js +24 -1
  48. package/lib/utils/applications-config-defaults.js +206 -0
  49. package/lib/utils/auth-config-validator.js +2 -12
  50. package/lib/utils/bash-secret-env.js +1 -1
  51. package/lib/utils/compose-generate-docker-compose.js +111 -6
  52. package/lib/utils/compose-generator.js +17 -8
  53. package/lib/utils/controller-url.js +50 -7
  54. package/lib/utils/env-copy.js +99 -14
  55. package/lib/utils/env-template.js +5 -1
  56. package/lib/utils/health-check-url.js +18 -15
  57. package/lib/utils/health-check.js +7 -5
  58. package/lib/utils/infra-optional-service-flags.js +69 -0
  59. package/lib/utils/installation-log-core.js +282 -0
  60. package/lib/utils/installation-log-record.js +237 -0
  61. package/lib/utils/installation-log.js +123 -0
  62. package/lib/utils/log-redaction.js +105 -0
  63. package/lib/utils/manifest-location.js +164 -0
  64. package/lib/utils/manifest-source-emit.js +162 -0
  65. package/lib/utils/paths.js +238 -89
  66. package/lib/utils/remote-secrets-loader.js +7 -1
  67. package/lib/utils/run-cli-flags.js +29 -0
  68. package/lib/utils/secrets-canonical.js +10 -3
  69. package/lib/utils/secrets-path.js +3 -4
  70. package/lib/utils/secrets-utils.js +20 -10
  71. package/lib/utils/system-builder-root.js +10 -2
  72. package/lib/utils/url-declarative-public-base.js +80 -12
  73. package/lib/utils/url-declarative-resolve-build-urls.js +238 -0
  74. package/lib/utils/url-declarative-resolve-build.js +24 -393
  75. package/lib/utils/url-declarative-resolve-expand-token.js +189 -0
  76. package/lib/utils/url-declarative-resolve-load-doc.js +12 -3
  77. package/lib/utils/url-declarative-resolve-surface-state.js +102 -0
  78. package/lib/utils/url-declarative-resolve.js +47 -7
  79. package/lib/utils/url-declarative-runtime-base-path.js +21 -1
  80. package/lib/utils/urls-local-registry-scan.js +103 -0
  81. package/lib/utils/urls-local-registry.js +161 -90
  82. package/package.json +3 -1
  83. package/templates/applications/dataplane/application.yaml +4 -0
  84. package/templates/applications/miso-controller/application.yaml +2 -0
  85. package/templates/applications/miso-controller/env.template +27 -29
  86. package/.npmrc.token +0 -1
@@ -0,0 +1,60 @@
1
+ /**
2
+ * URL and developer-id normalization for runtime config.
3
+ *
4
+ * @fileoverview Shared normalizers used by lib/core/config.js
5
+ * @author AI Fabrix Team
6
+ * @version 2.0.0
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ /**
12
+ * Normalize controller URL for consistent storage and lookup
13
+ * @param {string} url - Controller URL to normalize
14
+ * @returns {string|undefined|null} Normalized URL or original falsy
15
+ */
16
+ function normalizeControllerUrl(url) {
17
+ if (!url || typeof url !== 'string') {
18
+ return url;
19
+ }
20
+ let normalized = url.trim().replace(/\/+$/, '');
21
+ if (!normalized.match(/^https?:\/\//)) {
22
+ normalized = `http://${normalized}`;
23
+ }
24
+ return normalized;
25
+ }
26
+
27
+ /**
28
+ * Validate and normalize developer ID
29
+ * @param {*} developerId - Developer ID value (can be string, number, undefined, or null)
30
+ * @returns {string} Normalized developer ID as string
31
+ * @throws {Error} If developer ID is invalid
32
+ */
33
+ function validateAndNormalizeDeveloperId(developerId) {
34
+ const DEV_ID_DIGITS_REGEX = /^[0-9]+$/;
35
+
36
+ if (typeof developerId === 'undefined' || developerId === null) {
37
+ return '0';
38
+ }
39
+
40
+ if (typeof developerId === 'number') {
41
+ if (developerId < 0 || !Number.isFinite(developerId)) {
42
+ throw new Error('Developer ID must be a non-negative digit string or number');
43
+ }
44
+ return String(developerId);
45
+ }
46
+
47
+ if (typeof developerId === 'string') {
48
+ if (!DEV_ID_DIGITS_REGEX.test(developerId)) {
49
+ throw new Error('Developer ID must be a non-negative digit string or number');
50
+ }
51
+ return developerId;
52
+ }
53
+
54
+ throw new Error('Developer ID must be a non-negative digit string or number');
55
+ }
56
+
57
+ module.exports = {
58
+ normalizeControllerUrl,
59
+ validateAndNormalizeDeveloperId
60
+ };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Derive controller URLs stored in config (default + device token keys).
3
+ *
4
+ * @fileoverview Registered controller URL list for auth --set-controller pick mode
5
+ * @author AI Fabrix Team
6
+ * @version 2.0.0
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ /**
12
+ * Build sorted unique controller URLs from a config object.
13
+ * @param {Object} cfg - Parsed config.yaml object
14
+ * @param {(url: string) => string} normalizeControllerUrl - normalizer from config module
15
+ * @returns {string[]}
16
+ */
17
+ function buildRegisteredControllerUrlList(cfg, normalizeControllerUrl) {
18
+ const seen = new Set();
19
+ const add = (raw) => {
20
+ if (!raw || typeof raw !== 'string') return;
21
+ const trimmed = raw.trim();
22
+ if (!/^https?:\/\//i.test(trimmed)) return;
23
+ let parsed;
24
+ try {
25
+ parsed = new URL(trimmed);
26
+ } catch {
27
+ return;
28
+ }
29
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return;
30
+ seen.add(normalizeControllerUrl(trimmed));
31
+ };
32
+ if (cfg.controller) {
33
+ add(cfg.controller);
34
+ }
35
+ for (const key of Object.keys(cfg.device || {})) {
36
+ add(key);
37
+ }
38
+ return [...seen].sort((a, b) => a.localeCompare(b));
39
+ }
40
+
41
+ /**
42
+ * @param {() => Promise<Object>} getConfig - async config loader
43
+ * @param {(url: string) => string} normalizeControllerUrl
44
+ * @returns {Promise<string[]>}
45
+ */
46
+ async function getRegisteredControllerUrlsWithLoader(getConfig, normalizeControllerUrl) {
47
+ const cfg = await getConfig();
48
+ return buildRegisteredControllerUrlList(cfg, normalizeControllerUrl);
49
+ }
50
+
51
+ module.exports = {
52
+ buildRegisteredControllerUrlList,
53
+ getRegisteredControllerUrlsWithLoader
54
+ };
@@ -15,62 +15,15 @@ const os = require('os');
15
15
  const { encryptToken, decryptToken, isTokenEncrypted } = require('../utils/token-encryption');
16
16
  const { ensureSecureFilePermissions, ensureSecureDirPermissions } = require('../utils/secure-file-permissions');
17
17
  const { getRuntimeConfigDir, getRuntimeConfigFile } = require('./config-runtime-paths');
18
+ const { getAdminEmailFromConfig, setAdminEmailInConfig } = require('./config-admin-email');
19
+ const { getRegisteredControllerUrlsWithLoader } = require('./config-registered-controller-urls');
20
+ const { normalizeControllerUrl, validateAndNormalizeDeveloperId } = require('./config-normalize');
18
21
  // Avoid importing paths.js here to prevent circular dependency; use shared runtime config dir helper.
19
22
  // Config location: AIFABRIX_CONFIG dirname → AIFABRIX_HOME (with ~/.aifabrix fallback when config lives there) → ~/.aifabrix
20
23
 
21
24
  // Cache for developer ID - loaded when getConfig() is first called
22
25
  let cachedDeveloperId = null;
23
26
 
24
- /**
25
- * Normalize controller URL for consistent storage and lookup
26
- * Removes trailing slashes and normalizes the URL format
27
- * @param {string} url - Controller URL to normalize
28
- * @returns {string} Normalized controller URL
29
- */
30
- function normalizeControllerUrl(url) {
31
- if (!url || typeof url !== 'string') {
32
- return url;
33
- }
34
- // Remove trailing slashes
35
- let normalized = url.trim().replace(/\/+$/, '');
36
- // Ensure it starts with http:// or https://
37
- if (!normalized.match(/^https?:\/\//)) {
38
- // If it doesn't start with protocol, assume http://
39
- normalized = `http://${normalized}`;
40
- }
41
- return normalized;
42
- }
43
-
44
- /**
45
- * Validate and normalize developer ID
46
- * @param {*} developerId - Developer ID value (can be string, number, undefined, or null)
47
- * @returns {string} Normalized developer ID as string
48
- * @throws {Error} If developer ID is invalid
49
- */
50
- function validateAndNormalizeDeveloperId(developerId) {
51
- const DEV_ID_DIGITS_REGEX = /^[0-9]+$/;
52
-
53
- if (typeof developerId === 'undefined' || developerId === null) {
54
- return '0';
55
- }
56
-
57
- if (typeof developerId === 'number') {
58
- if (developerId < 0 || !Number.isFinite(developerId)) {
59
- throw new Error('Developer ID must be a non-negative digit string or number');
60
- }
61
- return String(developerId);
62
- }
63
-
64
- if (typeof developerId === 'string') {
65
- if (!DEV_ID_DIGITS_REGEX.test(developerId)) {
66
- throw new Error('Developer ID must be a non-negative digit string or number');
67
- }
68
- return developerId;
69
- }
70
-
71
- throw new Error('Developer ID must be a non-negative digit string or number');
72
- }
73
-
74
27
  /**
75
28
  * Ensure default config values exist
76
29
  * @param {Object} config - Configuration object
@@ -258,6 +211,25 @@ async function getTlsEnabled() {
258
211
  return cfg.tlsEnabled === true;
259
212
  }
260
213
 
214
+ /**
215
+ * Admin email stored in `config.yaml` by `aifabrix setup` (Keycloak / pgAdmin). Empty when unset.
216
+ *
217
+ * @returns {Promise<string>}
218
+ */
219
+ async function getAdminEmail() {
220
+ return getAdminEmailFromConfig(getConfig);
221
+ }
222
+
223
+ /**
224
+ * Persist admin email to `config.yaml` (used on subsequent setup runs as the prompt default).
225
+ *
226
+ * @param {string} email
227
+ * @returns {Promise<void>}
228
+ */
229
+ async function setAdminEmail(email) {
230
+ return setAdminEmailInConfig(getConfig, saveConfig, email);
231
+ }
232
+
261
233
  /**
262
234
  * Whether Traefik is enabled (`traefik: true` in config; infra compose includes the proxy).
263
235
  * @returns {Promise<boolean>}
@@ -316,6 +288,14 @@ async function getControllerUrl() {
316
288
  return config.controller || null;
317
289
  }
318
290
 
291
+ /**
292
+ * Controller URLs from config: `controller` plus `device` token keys.
293
+ * @returns {Promise<string[]>}
294
+ */
295
+ async function getRegisteredControllerUrls() {
296
+ return getRegisteredControllerUrlsWithLoader(getConfig, normalizeControllerUrl);
297
+ }
298
+
319
299
  function isTokenExpired(expiresAt) {
320
300
  if (!expiresAt) return true;
321
301
  const expirationTime = new Date(expiresAt).getTime();
@@ -454,6 +434,8 @@ const exportsObj = {
454
434
  loadDeveloperId,
455
435
  getCurrentEnvironment,
456
436
  getTlsEnabled,
437
+ getAdminEmail,
438
+ setAdminEmail,
457
439
  getTraefikEnabled,
458
440
  setCurrentEnvironment,
459
441
  resolveEnvironment,
@@ -469,6 +451,7 @@ const exportsObj = {
469
451
  normalizeControllerUrl,
470
452
  setControllerUrl,
471
453
  getControllerUrl,
454
+ getRegisteredControllerUrls,
472
455
  get CONFIG_DIR() {
473
456
  return getRuntimeConfigDir();
474
457
  },
@@ -71,7 +71,7 @@ function getInfraSecretKeysForUpInfra() {
71
71
  }
72
72
 
73
73
  /**
74
- * Same merge as runtime `loadSecrets()` (user local + project path + remote shared API + defaults).
74
+ * Same merge as runtime `loadSecrets()` (primary `~/.aifabrix/secrets.local.yaml` plus `aifabrix-secrets` file or remote API).
75
75
  * Ensures missing-key checks treat remote `--shared` keys as already satisfied.
76
76
  *
77
77
  * @returns {Promise<Object.<string, string>>}
@@ -37,8 +37,7 @@ const pathsUtil = require('../utils/paths');
37
37
  const { readAppEnvironmentScopedFlagForAppPath } = require('../utils/app-scoped-config');
38
38
  const { computeEffectiveEnvironmentScopedResources, redisDbIndexForScopedRunEnv } = require('../utils/environment-scoped-resources');
39
39
  const { applyRedisDbIndexToEnvContent } = require('../utils/redis-env-scope');
40
- const { expandDeclarativeUrlsInEnvContent } = require('../utils/url-declarative-resolve');
41
- const { rewriteInactiveDeclarativeVdirPublicContent } = require('../utils/url-declarative-vdir-inactive-env');
40
+ const { expandDeclarativeUrlsIfPresent } = require('./secrets-env-declarative-expand');
42
41
  const {
43
42
  mergeDockerManifestPublishedPort,
44
43
  rewriteDockerManifestPublicPortEnvLine
@@ -127,76 +126,6 @@ async function getDockerRedisDbEndpoints() {
127
126
  return { redisHost, redisPort, dbHost, dbPort };
128
127
  }
129
128
 
130
- /**
131
- * Config inputs for declarative url:// expansion (keeps expandDeclarativeUrlsIfPresent small).
132
- * @param {string} appPath
133
- * @returns {Promise<Object>}
134
- */
135
- async function loadDeclarativeUrlExpandInputs(appPath) {
136
- const userCfg = await config.getConfig();
137
- let remoteServer = null;
138
- try {
139
- const rs = await config.getRemoteServer();
140
- if (rs && String(rs).trim()) {
141
- remoteServer = String(rs).trim();
142
- }
143
- } catch {
144
- remoteServer = null;
145
- }
146
- let developerIdRaw = null;
147
- try {
148
- developerIdRaw = await config.getDeveloperId();
149
- } catch {
150
- developerIdRaw = null;
151
- }
152
- let infraTlsEnabled = false;
153
- try {
154
- infraTlsEnabled = await config.getTlsEnabled();
155
- } catch {
156
- infraTlsEnabled = false;
157
- }
158
- return {
159
- userCfg,
160
- remoteServer,
161
- developerIdRaw,
162
- infraTlsEnabled,
163
- appScopedFlag: readAppEnvironmentScopedFlagForAppPath(appPath)
164
- };
165
- }
166
-
167
- /**
168
- * After kv:// resolution, expand url:// references when application config exists.
169
- * @param {string} resolved
170
- * @param {string} appName
171
- * @param {string} appPath
172
- * @param {string|null} variablesPath
173
- * @param {string} environment
174
- * @param {boolean} envOnly
175
- * @returns {Promise<string>}
176
- */
177
- async function expandDeclarativeUrlsIfPresent(resolved, appName, appPath, variablesPath, environment, envOnly) {
178
- if (envOnly || !variablesPath) {
179
- return resolved;
180
- }
181
- const { userCfg, remoteServer, developerIdRaw, infraTlsEnabled, appScopedFlag } =
182
- await loadDeclarativeUrlExpandInputs(appPath);
183
- resolved = rewriteInactiveDeclarativeVdirPublicContent(resolved, variablesPath, userCfg);
184
- if (!resolved.includes('url://')) {
185
- return resolved;
186
- }
187
- return expandDeclarativeUrlsInEnvContent(resolved, {
188
- profile: environment === 'docker' ? 'docker' : 'local',
189
- currentAppKey: appName,
190
- variablesPath,
191
- useEnvironmentScopedResources: Boolean(userCfg.useEnvironmentScopedResources),
192
- appEnvironmentScopedResources: appScopedFlag,
193
- remoteServer,
194
- developerIdRaw,
195
- traefik: Boolean(userCfg.traefik),
196
- infraTlsEnabled
197
- });
198
- }
199
-
200
129
  /** Docker env transformations: ports, infra endpoints, PORT. */
201
130
  async function applyDockerTransformations(resolved, variablesPath) {
202
131
  resolved = await resolveServicePortsInEnvContent(resolved, 'docker');
@@ -337,17 +266,17 @@ function mergeEnvContentPreservingExisting(newContent, existingMap) {
337
266
  /**
338
267
  * Merges a key-value map into existing .env file content, preserving comments and blank lines.
339
268
  * For each KEY=value line in existing content, replaces value with newMap[KEY] when the key exists
340
- * in newMap. Appends any keys from newMap that did not appear in the file.
269
+ * in newMap. By default appends keys from newMap that did not appear in the file; set
270
+ * `appendMissingFromNewMap: false` to only update keys already present (e.g. `--reload` into a
271
+ * resolve-generated file so run-only keys like DB_0_NAME are not tacked on the end).
341
272
  *
342
273
  * @param {string} existingContent - Full existing .env file content
343
274
  * @param {Object.<string, string>} newMap - New key-to-value map (e.g. from resolved or run env)
275
+ * @param {Object} [options] - Merge options
276
+ * @param {boolean} [options.appendMissingFromNewMap=true] - When false, do not append keys only in newMap
344
277
  * @returns {string} Merged content with comments preserved
345
278
  */
346
- function mergeEnvMapIntoContent(existingContent, newMap) {
347
- if (!newMap || Object.keys(newMap).length === 0) {
348
- return typeof existingContent === 'string' ? existingContent : '';
349
- }
350
- const lines = (existingContent || '').split(/\r?\n/);
279
+ function collectSeenKeysAndMergeEnvLines(lines, newMap) {
351
280
  const seen = new Set();
352
281
  const out = [];
353
282
  for (const line of lines) {
@@ -365,8 +294,26 @@ function mergeEnvMapIntoContent(existingContent, newMap) {
365
294
  }
366
295
  out.push(line);
367
296
  }
297
+ return { out, seen };
298
+ }
299
+
300
+ function appendMissingEnvKeysFromMap(out, seen, newMap) {
368
301
  for (const key of Object.keys(newMap)) {
369
- if (!seen.has(key)) out.push(`${key}=${newMap[key]}`);
302
+ if (!seen.has(key)) {
303
+ out.push(`${key}=${newMap[key]}`);
304
+ }
305
+ }
306
+ }
307
+
308
+ function mergeEnvMapIntoContent(existingContent, newMap, options = {}) {
309
+ if (!newMap || Object.keys(newMap).length === 0) {
310
+ return typeof existingContent === 'string' ? existingContent : '';
311
+ }
312
+ const appendMissing = options.appendMissingFromNewMap !== false;
313
+ const lines = (existingContent || '').split(/\r?\n/);
314
+ const { out, seen } = collectSeenKeysAndMergeEnvLines(lines, newMap);
315
+ if (appendMissing) {
316
+ appendMissingEnvKeysFromMap(out, seen, newMap);
370
317
  }
371
318
  return out.join('\n');
372
319
  }
@@ -386,19 +333,72 @@ function resolveEnvContentToWrite(resolved, pathToPreserve) {
386
333
 
387
334
  /**
388
335
  * Generates and writes .env file. Newly resolved values win over existing .env; extra vars in existing .env are kept.
389
- * When options.envOnly is true, only env.template is used; .env is written to options.appPath.
336
+ * When `options.envOnly` is true, only env.template is used; .env is written to `options.appPath`.
337
+ *
338
+ * When `options.noWrite` is true, the function resolves the .env content in memory and skips
339
+ * both writes — neither `<appPath>/.env` nor `build.envOutputPath` is materialized — and returns
340
+ * `null`. Use this from non-resolve flows (register/rotate-secret/build/up-*) so resolved secrets
341
+ * never land on disk except when the user runs `aifabrix resolve <app>` explicitly.
342
+ *
390
343
  * @async
391
344
  * @param {string} appName - Name of the application
392
345
  * @param {string} [secretsPath] - Path to secrets file (optional)
393
346
  * @param {string} [environment='local'] - Environment context ('local' or 'docker')
394
347
  * @param {boolean} [force=false] - Generate missing secret keys in secrets file
395
- * @param {Object} [options] - Optional: appPath, envOnly, skipOutputPath, preserveFromPath
396
- * @returns {Promise<string>} Path to generated .env file
348
+ * @param {Object} [options] - Optional: appPath, envOnly, skipOutputPath, preserveFromPath, noWrite,
349
+ * preferLocalEnvOutputPath (when **true**, `build.envOutputPath` is regenerated with **local** `url://` profile; **false**
350
+ * only when **both** `remote-server` is set and `applications.<app>.reload` is true — then docker flavor matches `builder/<app>/.env`)
351
+ * @param {boolean} [options.noWrite=false] - When true, resolve in-memory only; do not write
352
+ * `<appPath>/.env` and do not call `processEnvVariables`. Returns `null` in that case.
353
+ * @returns {Promise<string|null>} Path to generated .env file, or `null` when `noWrite` is true
354
+ *
355
+ * @example
356
+ * // up-platform / up-miso / up-dataplane / register / rotate-secret / build flows:
357
+ * await generateEnvFile('dataplane', null, 'local', false, { noWrite: true });
358
+ *
359
+ * @example
360
+ * // aifabrix resolve <app> — the only legitimate writer of a persistent .env:
361
+ * await generateEnvFile('dataplane', undefined, 'docker', force, {
362
+ * appPath, envOnly, skipOutputPath: false, preserveFromPath: null
363
+ * });
397
364
  */
365
+ /**
366
+ * Materialize a resolved .env to `<appPath>/.env` and (optionally) copy through
367
+ * `build.envOutputPath`. Extracted so {@link generateEnvFile} can stay under the
368
+ * 20-statement limit while still expressing the in-memory vs on-disk branch clearly.
369
+ *
370
+ * @async
371
+ * @param {Object} params
372
+ * @param {string} params.appName
373
+ * @param {string} params.appPath
374
+ * @param {string} params.envPath - Resolved `<appPath>/.env` target
375
+ * @param {string} params.resolved - Fully resolved .env content
376
+ * @param {string|null} params.variablesPath - application.yaml path (or null when envOnly)
377
+ * @param {string} [params.secretsPath]
378
+ * @param {Object} params.opts - Caller options (preserveFromPath, skipOutputPath, preferLocalEnvOutputPath, appPath)
379
+ * @returns {Promise<string>} Path to the written .env file
380
+ */
381
+ async function writeResolvedEnv({ appName, envPath, resolved, variablesPath, secretsPath, opts }) {
382
+ const preservePath = opts.preserveFromPath !== undefined && opts.preserveFromPath !== null ? opts.preserveFromPath : null;
383
+ const pathToPreserve = preservePath !== null ? preservePath : envPath;
384
+ const toWrite = resolveEnvContentToWrite(resolved, pathToPreserve);
385
+ fs.writeFileSync(envPath, toWrite, { mode: 0o600 });
386
+
387
+ if (!opts.skipOutputPath) {
388
+ const { processEnvVariables } = require('../utils/env-copy');
389
+ await processEnvVariables(envPath, variablesPath, appName, secretsPath, {
390
+ preferLocalEnvOutputPath: opts.preferLocalEnvOutputPath === true,
391
+ appPath: opts.appPath || null
392
+ });
393
+ }
394
+ return envPath;
395
+ }
396
+
398
397
  async function generateEnvFile(appName, secretsPath, environment = 'local', force = false, options = {}) {
399
398
  const opts = options && typeof options === 'object' ? options : {};
400
399
  const appPath = opts.appPath || pathsUtil.getBuilderPath(appName);
401
400
  const envOnly = !!opts.envOnly;
401
+ const noWrite = opts.noWrite === true;
402
402
  const variablesPath = envOnly ? null : resolveApplicationConfigPath(appPath);
403
403
  const envPath = path.join(appPath, '.env');
404
404
 
@@ -409,18 +409,14 @@ async function generateEnvFile(appName, secretsPath, environment = 'local', forc
409
409
  }
410
410
  }
411
411
 
412
+ // Always resolve so missing-secret / kv:// errors still surface in noWrite mode.
412
413
  const resolved = await generateEnvContent(appName, secretsPath, environment, force, { appPath, envOnly });
413
- const preservePath = opts.preserveFromPath !== undefined && opts.preserveFromPath !== null ? opts.preserveFromPath : null;
414
- const pathToPreserve = preservePath !== null ? preservePath : envPath;
415
- const toWrite = resolveEnvContentToWrite(resolved, pathToPreserve);
416
- fs.writeFileSync(envPath, toWrite, { mode: 0o600 });
417
414
 
418
- if (!opts.skipOutputPath) {
419
- const { processEnvVariables } = require('../utils/env-copy');
420
- await processEnvVariables(envPath, variablesPath, appName, secretsPath);
415
+ if (noWrite) {
416
+ return null;
421
417
  }
422
418
 
423
- return envPath;
419
+ return writeResolvedEnv({ appName, appPath, envPath, resolved, variablesPath, secretsPath, opts });
424
420
  }
425
421
 
426
422
  module.exports = {
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Declarative url:// expansion after kv:// (keeps secrets-env-content under max-lines).
3
+ *
4
+ * @fileoverview Declarative url:// expansion after kv://; shared ctx builder; show URL helper
5
+ * @author AI Fabrix Team
6
+ * @version 1.0.0
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const config = require('./config');
12
+ const pathsUtil = require('../utils/paths');
13
+ const { readAppEnvironmentScopedFlagForAppPath } = require('../utils/app-scoped-config');
14
+ const { expandDeclarativeUrlsInEnvContent } = require('../utils/url-declarative-resolve');
15
+ const { rewriteInactiveDeclarativeVdirPublicContent } = require('../utils/url-declarative-vdir-inactive-env');
16
+ const { refreshUrlsLocalRegistryFromBuilder } = require('../utils/urls-local-registry');
17
+ /**
18
+ * Config inputs for declarative url:// expansion.
19
+ * @param {string} appPath
20
+ * @returns {Promise<Object>}
21
+ */
22
+ async function loadDeclarativeUrlExpandInputs(appPath) {
23
+ const userCfg = await config.getConfig();
24
+ let remoteServer = null;
25
+ try {
26
+ const rs = await config.getRemoteServer();
27
+ if (rs && String(rs).trim()) {
28
+ remoteServer = String(rs).trim();
29
+ }
30
+ } catch {
31
+ remoteServer = null;
32
+ }
33
+ let developerIdRaw = null;
34
+ try {
35
+ developerIdRaw = await config.getDeveloperId();
36
+ } catch {
37
+ developerIdRaw = null;
38
+ }
39
+ let infraTlsEnabled = false;
40
+ try {
41
+ infraTlsEnabled = await config.getTlsEnabled();
42
+ } catch {
43
+ infraTlsEnabled = false;
44
+ }
45
+ return {
46
+ userCfg,
47
+ remoteServer,
48
+ developerIdRaw,
49
+ infraTlsEnabled,
50
+ appScopedFlag: readAppEnvironmentScopedFlagForAppPath(appPath)
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Build ctx for {@link expandDeclarativeUrlsInEnvContent} from preloaded inputs.
56
+ * @param {string} appName
57
+ * @param {string|null|undefined} variablesPath
58
+ * @param {string} environment
59
+ * @param {Object} inputs - Result of {@link loadDeclarativeUrlExpandInputs}
60
+ * @returns {Object}
61
+ */
62
+ function buildDeclarativeUrlExpandContextFromInputs(appName, variablesPath, environment, inputs) {
63
+ const { userCfg, remoteServer, developerIdRaw, infraTlsEnabled, appScopedFlag } = inputs;
64
+ let projectRoot = null;
65
+ try {
66
+ const r = pathsUtil.getProjectRoot();
67
+ if (r && String(r).trim()) {
68
+ projectRoot = String(r).trim();
69
+ }
70
+ } catch {
71
+ projectRoot = null;
72
+ }
73
+ return {
74
+ profile: environment === 'docker' ? 'docker' : 'local',
75
+ currentAppKey: appName,
76
+ variablesPath,
77
+ useEnvironmentScopedResources: Boolean(userCfg.useEnvironmentScopedResources),
78
+ appEnvironmentScopedResources: appScopedFlag,
79
+ remoteServer,
80
+ developerIdRaw,
81
+ infraTlsEnabled,
82
+ userCfg,
83
+ projectRoot
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Resolve `url://public` and `url://internal` for the app (same rules as run `.env`, default `docker` profile).
89
+ * @param {string} appKey
90
+ * @param {string} appPath
91
+ * @param {string|null|undefined} variablesPath
92
+ * @param {string} [environment='docker'] - Matches `secrets-env-write` default so `url://internal` uses the same service host + vdir rules as the app `.env` copied for Docker run.
93
+ * @returns {Promise<{ publicUrl: string, internalUrl: string }|null>}
94
+ */
95
+ async function resolveDeclarativeShowUrlsForApp(appKey, appPath, variablesPath, environment = 'docker') {
96
+ if (!variablesPath || !appPath || !appKey) {
97
+ return null;
98
+ }
99
+ const inputs = await loadDeclarativeUrlExpandInputs(appPath);
100
+ const { parseSimpleEnvMap } = require('../utils/url-declarative-resolve');
101
+ let content = 'APP_SHOW_PUBLIC=url://public\nAPP_SHOW_INTERNAL=url://internal\n';
102
+ content = rewriteInactiveDeclarativeVdirPublicContent(content, variablesPath, inputs.userCfg);
103
+ if (!content.includes('url://')) {
104
+ return null;
105
+ }
106
+ const ctx = buildDeclarativeUrlExpandContextFromInputs(appKey, variablesPath, environment, inputs);
107
+ const out = await expandDeclarativeUrlsInEnvContent(content, ctx);
108
+ const m = parseSimpleEnvMap(out);
109
+ const publicUrl = m.APP_SHOW_PUBLIC;
110
+ const internalUrl = m.APP_SHOW_INTERNAL;
111
+ if (
112
+ !publicUrl ||
113
+ !internalUrl ||
114
+ String(publicUrl).includes('url://') ||
115
+ String(internalUrl).includes('url://')
116
+ ) {
117
+ return null;
118
+ }
119
+ return { publicUrl: String(publicUrl).trim(), internalUrl: String(internalUrl).trim() };
120
+ }
121
+
122
+ /**
123
+ * After kv:// resolution, expand url:// references when application config exists.
124
+ * Also refreshes {@link refreshUrlsLocalRegistryFromBuilder} whenever `variablesPath` is set so
125
+ * `urls.local.yaml` picks up `port` / `frontDoorRouting` changes even if `env.template` has no
126
+ * literal `url://` placeholders (they may already be expanded or absent).
127
+ * @param {string} resolved
128
+ * @param {string} appName
129
+ * @param {string} appPath
130
+ * @param {string|null} variablesPath
131
+ * @param {string} environment
132
+ * @param {boolean} envOnly
133
+ * @returns {Promise<string>}
134
+ */
135
+ async function expandDeclarativeUrlsIfPresent(resolved, appName, appPath, variablesPath, environment, envOnly) {
136
+ if (envOnly || !variablesPath) {
137
+ return resolved;
138
+ }
139
+ const { userCfg, remoteServer, developerIdRaw, infraTlsEnabled, appScopedFlag } =
140
+ await loadDeclarativeUrlExpandInputs(appPath);
141
+ resolved = rewriteInactiveDeclarativeVdirPublicContent(resolved, variablesPath, userCfg);
142
+ const ctx = buildDeclarativeUrlExpandContextFromInputs(appName, variablesPath, environment, {
143
+ userCfg,
144
+ remoteServer,
145
+ developerIdRaw,
146
+ infraTlsEnabled,
147
+ appScopedFlag
148
+ });
149
+ try {
150
+ const pr = ctx.projectRoot && String(ctx.projectRoot).trim() ? String(ctx.projectRoot).trim() : '';
151
+ const registryRoot = pr || pathsUtil.getProjectRoot();
152
+ if (ctx.excludeCwdBuilderScan === true) {
153
+ refreshUrlsLocalRegistryFromBuilder(registryRoot, { excludeCwdBuilderScan: true });
154
+ } else {
155
+ refreshUrlsLocalRegistryFromBuilder(registryRoot);
156
+ }
157
+ } catch {
158
+ // best-effort: registry refresh must not block .env generation
159
+ }
160
+ if (!resolved.includes('url://')) {
161
+ return resolved;
162
+ }
163
+ return expandDeclarativeUrlsInEnvContent(resolved, ctx);
164
+ }
165
+
166
+ module.exports = {
167
+ expandDeclarativeUrlsIfPresent,
168
+ loadDeclarativeUrlExpandInputs,
169
+ resolveDeclarativeShowUrlsForApp
170
+ };
@@ -149,6 +149,8 @@ function parseEnvContentToMap(content) {
149
149
  * Resolve .env in memory and write only to envOutputPath or temp (no builder/ or integration/).
150
150
  * Injects NPM_TOKEN and PYPI_TOKEN from secrets when missing, then from process.env, then names derived from `BASH_*` keys in the merged secrets store, so shell/install/build match private registry tooling.
151
151
  *
152
+ * **Ephemeral / tooling path:** Used by `app-install`, `app-shell`, and `app-test` to materialize a disposable `.env` (defaults to a temp file under `os.tmpdir()`). This is **not** the same as `aifabrix resolve <app>` (which uses `generateEnvFile` and preserves `build.envOutputPath` / comments). Prefer `aifabrix resolve` when you need a durable repo `.env` for local development.
153
+ *
152
154
  * @async
153
155
  * @function resolveAndWriteEnvFile
154
156
  * @param {string} appName - Application name