@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
@@ -1,7 +1,9 @@
1
1
  /**
2
- * Secrets loading cascade (user file, aifabrix-secrets file or remote API, builder YAML, defaults).
2
+ * Secrets loading: primary user file plus configured `aifabrix-secrets` (shared YAML or remote API).
3
3
  *
4
- * @fileoverview Split from secrets.js for module size limits
4
+ * @fileoverview Default `kv://` resolution uses only `~/.aifabrix/secrets.local.yaml` and `aifabrix-secrets`
5
+ * from config — not cwd-ancestor `.aifabrix/secrets.local.yaml`, not `builder/secrets.local.yaml`,
6
+ * and not `~/.aifabrix/secrets.yaml`.
5
7
  * @author AI Fabrix Team
6
8
  * @version 1.0.0
7
9
  */
@@ -13,27 +15,75 @@ const path = require('path');
13
15
  const config = require('./config');
14
16
  const { readYamlAtPath, applyCanonicalSecretsOverride } = require('../utils/secrets-canonical');
15
17
  const { ensureSecureFilePermissions } = require('../utils/secure-file-permissions');
18
+ const { resolveSecretsPath } = require('../utils/secrets-path');
16
19
  const {
17
- resolveSecretsPath
18
- } = require('../utils/secrets-path');
19
- const {
20
- loadUserSecrets,
21
20
  loadPrimaryUserSecrets,
22
21
  loadDefaultSecrets,
23
22
  ensurePrimaryUserSecretsFileExists
24
23
  } = require('../utils/secrets-utils');
25
- const { decryptSecret, isEncrypted } = require('../utils/secrets-encryption');
26
24
  const pathsUtil = require('../utils/paths');
27
- const { collectAncestorAifabrixSecretsLocalYamlPaths } = require('../utils/secrets-ancestor-paths');
25
+ const { decryptSecret, isEncrypted } = require('../utils/secrets-encryption');
26
+ const logger = require('../utils/logger');
27
+ const { isSecretKeyAllowedEmpty } = require('./secrets-ensure-infra');
28
+
29
+ /** Dedupe optional decrypt warnings across repeated `loadSecrets` (and duplicate module instances). */
30
+ function optionalDecryptWarnSeen(key) {
31
+ const g = globalThis;
32
+ if (!g.__aifabrixOptionalDecryptWarnOnce) {
33
+ g.__aifabrixOptionalDecryptWarnOnce = new Set();
34
+ }
35
+ const set = g.__aifabrixOptionalDecryptWarnOnce;
36
+ if (set.has(key)) return true;
37
+ set.add(key);
38
+ return false;
39
+ }
40
+
41
+ /**
42
+ * @param {string} key
43
+ * @param {unknown} value
44
+ * @param {string} encryptionKey
45
+ * @param {Record<string, string>} decryptedSecrets
46
+ * @param {string} [sourceHint] - File path or API label for decrypt errors / warnings
47
+ */
48
+ function mergeDecryptedEntry(key, value, encryptionKey, decryptedSecrets, sourceHint) {
49
+ if (!isEncrypted(value)) {
50
+ decryptedSecrets[key] = value;
51
+ return;
52
+ }
53
+ try {
54
+ decryptedSecrets[key] = decryptSecret(value, encryptionKey);
55
+ } catch (error) {
56
+ const msg = error && error.message ? error.message : String(error);
57
+ if (!isSecretKeyAllowedEmpty(key)) {
58
+ const where =
59
+ sourceHint && String(sourceHint).trim().length > 0
60
+ ? ` (encrypted value loaded from: ${sourceHint})`
61
+ : ' (encrypted value source not recorded; check ~/.aifabrix/secrets.local.yaml and aifabrix-secrets)';
62
+ throw new Error(`Failed to decrypt secret '${key}'${where}: ${msg}`);
63
+ }
64
+ if (!optionalDecryptWarnSeen(key)) {
65
+ const where =
66
+ sourceHint && String(sourceHint).trim().length > 0
67
+ ? ` Encrypted value loaded from: ${sourceHint}.`
68
+ : '';
69
+ logger.warn(
70
+ `Optional secret '${key}' could not be decrypted (${msg}). Treating as empty.${where} ` +
71
+ 'Remove the stale key from shared or local secrets, or fix secrets-encryption alignment.'
72
+ );
73
+ }
74
+ decryptedSecrets[key] = '';
75
+ }
76
+ }
28
77
 
29
78
  /**
30
79
  * Decrypts encrypted values in secrets object
31
80
  *
32
81
  * @async
33
82
  * @param {Object} secrets - Secrets object with potentially encrypted values
83
+ * @param {{ keySources?: Record<string, string>, defaultSourceLabel?: string }} [options] - Per-key file/API path for errors
34
84
  * @returns {Promise<Object>} Secrets object with decrypted values
35
85
  */
36
- async function decryptSecretsObject(secrets) {
86
+ async function decryptSecretsObject(secrets, options) {
37
87
  if (!secrets || typeof secrets !== 'object') {
38
88
  return secrets;
39
89
  }
@@ -49,17 +99,13 @@ async function decryptSecretsObject(secrets) {
49
99
  return secrets;
50
100
  }
51
101
 
102
+ const keySources = options && options.keySources ? options.keySources : null;
103
+ const defaultLabel = options && options.defaultSourceLabel ? options.defaultSourceLabel : '';
104
+
52
105
  const decryptedSecrets = {};
53
106
  for (const [key, value] of Object.entries(secrets)) {
54
- if (isEncrypted(value)) {
55
- try {
56
- decryptedSecrets[key] = decryptSecret(value, encryptionKey);
57
- } catch (error) {
58
- throw new Error(`Failed to decrypt secret '${key}': ${error.message}`);
59
- }
60
- } else {
61
- decryptedSecrets[key] = value;
62
- }
107
+ const sourceHint = (keySources && keySources[key]) || defaultLabel || '';
108
+ mergeDecryptedEntry(key, value, encryptionKey, decryptedSecrets, sourceHint);
63
109
  }
64
110
 
65
111
  return decryptedSecrets;
@@ -67,8 +113,9 @@ async function decryptSecretsObject(secrets) {
67
113
 
68
114
  /**
69
115
  * Merges config file secrets into user secrets (user wins). Returns null if path missing or config empty.
116
+ * @param {Record<string, string>} [keySources] - Mutated: path for keys filled from this file
70
117
  */
71
- function mergeUserWithConfigFile(userSecrets, resolvedConfigPath) {
118
+ function mergeUserWithConfigFile(userSecrets, resolvedConfigPath, keySources) {
72
119
  if (!fs.existsSync(resolvedConfigPath)) {
73
120
  return null;
74
121
  }
@@ -86,6 +133,9 @@ function mergeUserWithConfigFile(userSecrets, resolvedConfigPath) {
86
133
  for (const key of Object.keys(configSecrets)) {
87
134
  if (!(key in merged) || merged[key] === undefined || merged[key] === null || merged[key] === '') {
88
135
  merged[key] = configSecrets[key];
136
+ if (keySources) {
137
+ keySources[key] = resolvedConfigPath;
138
+ }
89
139
  }
90
140
  }
91
141
  return merged;
@@ -99,122 +149,74 @@ function createMergeHelpers(userSecrets) {
99
149
  };
100
150
  }
101
151
 
102
- async function mergeFromConfiguredSecretsPath(configSecretsPath, userSecrets, helpers) {
152
+ async function mergeFromConfiguredSecretsPath(configSecretsPath, userSecrets, helpers, keySources) {
103
153
  const remoteDevAuth = require('../utils/remote-dev-auth');
104
154
  const effectiveShared = await remoteDevAuth.resolveSharedSecretsEndpoint(configSecretsPath);
105
155
 
106
156
  if (remoteDevAuth.isRemoteSecretsUrl(effectiveShared)) {
107
157
  const { loadRemoteSharedSecrets, mergeUserWithRemoteSecrets } = require('../utils/remote-secrets-loader');
108
158
  const remoteSecrets = await loadRemoteSharedSecrets();
109
- const merged = mergeUserWithRemoteSecrets(userSecrets, remoteSecrets);
159
+ const remoteLabel = `shared secrets API (${effectiveShared})`;
160
+ const merged = mergeUserWithRemoteSecrets(userSecrets, remoteSecrets, keySources, remoteLabel);
110
161
  return helpers.hasKeys(merged) ? merged : helpers.userOrNull();
111
162
  }
112
163
 
113
164
  const resolvedConfigPath = path.isAbsolute(configSecretsPath)
114
165
  ? configSecretsPath
115
166
  : path.resolve(process.cwd(), configSecretsPath);
116
- const merged = mergeUserWithConfigFile(userSecrets, resolvedConfigPath);
167
+ const merged = mergeUserWithConfigFile(userSecrets, resolvedConfigPath, keySources);
117
168
  return merged !== null ? merged : helpers.userOrNull();
118
169
  }
119
170
 
120
171
  async function loadMergedConfigAndUserSecrets() {
121
172
  const userSecrets = loadPrimaryUserSecrets();
122
173
  const helpers = createMergeHelpers(userSecrets);
174
+ const userPath = pathsUtil.getPrimaryUserSecretsLocalPath();
175
+ /** @type {Record<string, string>} */
176
+ const keySources = {};
177
+ for (const k of Object.keys(userSecrets || {})) {
178
+ keySources[k] = userPath;
179
+ }
123
180
 
124
181
  try {
125
182
  const configSecretsPath = await config.getSecretsPath();
126
183
  if (!configSecretsPath) {
127
- return helpers.userOrNull();
184
+ return { merged: helpers.userOrNull(), keySources };
128
185
  }
129
- return await mergeFromConfiguredSecretsPath(configSecretsPath, userSecrets, helpers);
186
+ const merged = await mergeFromConfiguredSecretsPath(configSecretsPath, userSecrets, helpers, keySources);
187
+ return { merged, keySources };
130
188
  } catch (error) {
131
189
  if (error.message && error.message.startsWith('Failed to load secrets file')) {
132
190
  throw error;
133
191
  }
134
- return null;
135
- }
136
- }
137
-
138
- function collectBuilderSecretsYamlPaths() {
139
- const projectRoot = pathsUtil.getProjectRoot();
140
- const candidates = [];
141
- if (projectRoot) {
142
- candidates.push(path.join(projectRoot, 'builder', 'secrets.local.yaml'));
143
- }
144
- try {
145
- const alt = path.join(pathsUtil.getBuilderRoot(), 'secrets.local.yaml');
146
- if (!candidates.length || path.resolve(candidates[0]) !== path.resolve(alt)) {
147
- candidates.push(alt);
148
- }
149
- } catch {
150
- /* ignore */
151
- }
152
- return candidates;
153
- }
154
-
155
- function mergeBuilderSecretsLocalFiles(merged) {
156
- try {
157
- const seen = new Set();
158
- let out = merged;
159
- for (const builderPath of collectBuilderSecretsYamlPaths()) {
160
- if (!builderPath || seen.has(path.resolve(builderPath))) {
161
- continue;
162
- }
163
- seen.add(path.resolve(builderPath));
164
- if (fs.existsSync(builderPath)) {
165
- ensureSecureFilePermissions(builderPath);
166
- const builderSecrets = mergeUserWithConfigFile(out || {}, builderPath);
167
- if (builderSecrets) out = builderSecrets;
168
- }
169
- }
170
- return out;
171
- } catch {
172
- return merged;
173
- }
174
- }
175
-
176
- /**
177
- * Merge each `cwd/.aifabrix/secrets.local.yaml` found walking up to filesystem root.
178
- * Later files only fill keys missing or empty on the merged object (same as shared merge).
179
- * Skips the primary user file path when it appears on the walk (already loaded in merge chain).
180
- *
181
- * @param {Object|null|undefined} merged - Current secrets map
182
- * @returns {Object}
183
- */
184
- function mergeAncestorAifabrixSecretsLocalFiles(merged) {
185
- try {
186
- const primaryUser = path.resolve(pathsUtil.getPrimaryUserSecretsLocalPath());
187
- let out = merged && typeof merged === 'object' ? merged : {};
188
- const ancestorPaths = collectAncestorAifabrixSecretsLocalYamlPaths(process.cwd(), fs.existsSync);
189
- for (const secretsPath of ancestorPaths) {
190
- if (path.resolve(secretsPath) === primaryUser) {
191
- continue;
192
- }
193
- ensureSecureFilePermissions(secretsPath);
194
- const next = mergeUserWithConfigFile(out, secretsPath);
195
- if (next) out = next;
196
- }
197
- return out;
198
- } catch {
199
- return merged && typeof merged === 'object' ? merged : {};
192
+ return { merged: null, keySources };
200
193
  }
201
194
  }
202
195
 
203
196
  async function loadSecretsWithFallbacks() {
204
- let merged = await loadMergedConfigAndUserSecrets();
197
+ const mergedResult = await loadMergedConfigAndUserSecrets();
198
+ let merged = mergedResult.merged;
199
+ const keySources = mergedResult.keySources;
205
200
  if (!merged || Object.keys(merged).length === 0) {
206
201
  merged = loadPrimaryUserSecrets();
207
- if (Object.keys(merged).length === 0) {
208
- merged = loadUserSecrets();
202
+ const userPath = pathsUtil.getPrimaryUserSecretsLocalPath();
203
+ for (const k of Object.keys(merged || {})) {
204
+ keySources[k] = userPath;
209
205
  }
210
- merged = await applyCanonicalSecretsOverride(merged);
206
+ merged = await applyCanonicalSecretsOverride(merged || {}, keySources);
211
207
  }
212
- merged = mergeAncestorAifabrixSecretsLocalFiles(merged);
213
- merged = mergeBuilderSecretsLocalFiles(merged);
214
- if (Object.keys(merged).length === 0) {
208
+ const configuredShared = await config.getSecretsPath();
209
+ if ((!merged || Object.keys(merged).length === 0) && !configuredShared) {
215
210
  merged = loadDefaultSecrets();
211
+ const defaultPath = path.join(pathsUtil.getAifabrixHome(), 'secrets.yaml');
212
+ for (const k of Object.keys(merged || {})) {
213
+ keySources[k] = defaultPath;
214
+ }
216
215
  }
217
- return merged;
216
+ if (!merged || Object.keys(merged).length === 0) {
217
+ return { merged: {}, keySources };
218
+ }
219
+ return { merged, keySources };
218
220
  }
219
221
 
220
222
  /**
@@ -232,14 +234,16 @@ async function loadSecrets(secretsPath, _appName) {
232
234
  if (!explicitSecrets || typeof explicitSecrets !== 'object') {
233
235
  throw new Error(`Invalid secrets file format: ${resolvedPath}`);
234
236
  }
235
- return await decryptSecretsObject(explicitSecrets);
237
+ return await decryptSecretsObject(explicitSecrets, { defaultSourceLabel: resolvedPath });
236
238
  }
237
- let mergedSecrets = await loadSecretsWithFallbacks();
239
+ let { merged: mergedSecrets, keySources } = await loadSecretsWithFallbacks();
238
240
  if (!mergedSecrets || Object.keys(mergedSecrets).length === 0) {
239
241
  ensurePrimaryUserSecretsFileExists();
240
- mergedSecrets = await loadSecretsWithFallbacks();
242
+ const again = await loadSecretsWithFallbacks();
243
+ mergedSecrets = again.merged;
244
+ keySources = again.keySources;
241
245
  }
242
- return await decryptSecretsObject(mergedSecrets || {});
246
+ return await decryptSecretsObject(mergedSecrets || {}, { keySources });
243
247
  }
244
248
 
245
249
  module.exports = {
@@ -169,7 +169,7 @@ function logImmediateControllerDeploymentOutcome(deploymentOutcome) {
169
169
  );
170
170
  return;
171
171
  }
172
- logger.log(chalk.gray(' See Deployment section below for details.'));
172
+ logger.log(chalk.gray(' Check the controller deployment job or logs for details.'));
173
173
  }
174
174
 
175
175
  async function executeDeployAndDisplay(manifest, controllerUrl, environment, authConfig, options) {
@@ -192,6 +192,10 @@ async function executeDeployAndDisplay(manifest, controllerUrl, environment, aut
192
192
  const deploymentOutcome = parseControllerDeploymentOutcome(result);
193
193
  logImmediateControllerDeploymentOutcome(deploymentOutcome);
194
194
 
195
+ if (!deploymentOutcome.ok) {
196
+ return result;
197
+ }
198
+
195
199
  const ctx = await fetchDataplaneDeployReadiness(
196
200
  controllerUrl,
197
201
  environment,
@@ -72,6 +72,7 @@ function buildBoundFs() {
72
72
  mkdirSync: snap.mkdirSync,
73
73
  readdirSync: snap.readdirSync,
74
74
  statSync: snap.statSync,
75
+ renameSync: snap.renameSync,
75
76
  watch: (...args) => live.watch(...args),
76
77
  promises: boundPromises
77
78
  };
@@ -83,6 +84,7 @@ function buildBoundFs() {
83
84
  mkdirSync: bindSync(rf, 'mkdirSync'),
84
85
  readdirSync: bindSync(rf, 'readdirSync'),
85
86
  statSync: bindSync(rf, 'statSync'),
87
+ renameSync: bindSync(rf, 'renameSync'),
86
88
  watch: (...args) => live.watch(...args),
87
89
  promises: boundPromises
88
90
  };
@@ -534,6 +534,10 @@
534
534
  "pattern": "^[a-zA-Z.$\\{\\}_-]+$",
535
535
  "default": true
536
536
  },
537
+ "internalDockerUseOriginOnly": {
538
+ "type": "boolean",
539
+ "description": "When true, declarative url://*-internal full URLs in Docker profile use http://service:containerPort only (omit frontDoorRouting.pattern). Use when the ingress path (e.g. /miso) is not part of in-container routes. Omit or false to keep pattern on internal URLs (e.g. Keycloak /auth)."
540
+ },
537
541
  "certStore": {
538
542
  "type": "string",
539
543
  "description": "Certificate store name for wildcard certificates. Optional - only needed when using a pre-configured certificate store in Traefik.",
@@ -342,6 +342,16 @@ parameters:
342
342
  azure:
343
343
  notes: Empty until set; user-supplied Azure OpenAI API key (not auto-generated).
344
344
 
345
+ # Legacy unprefixed name (scaffold / old env.template); prefer *KeyVault suffix or {appKey}-secrets-apiKeyVault (keyvault.md).
346
+ - key: api-key
347
+ scope: app
348
+ generator:
349
+ type: randomBytes32
350
+ ensureOn: [resolveApp]
351
+ azure:
352
+ notes: >-
353
+ Legacy kv://api-key; new apps should use kv://api-keyKeyVault or {appKey}-secrets-apiKeyVault.
354
+
345
355
  # Legacy unprefixed name; prefer kv://{appKey}-secrets-apiKeyVault in env.template (keyvault.md secrets.apiKeyVault).
346
356
  - key: miso-controller-secrets-apiKeyVault
347
357
  scope: app
@@ -12,7 +12,28 @@
12
12
  'use strict';
13
13
 
14
14
  const path = require('path');
15
- const fs = require('fs');
15
+ const defaultFs = require('fs');
16
+ const { nodeFs } = require('../internal/node-fs');
17
+
18
+ const realFs = nodeFs();
19
+
20
+ /**
21
+ * Prefer real disk when `fs` is not mocked (avoids `jest.mock('fs')` bleed in other suites).
22
+ * Use `require('fs')` when Jest replaced it so tests with virtual file stores still work.
23
+ *
24
+ * @returns {Pick<import('fs'), 'existsSync'|'renameSync'>}
25
+ */
26
+ function getResolverFs() {
27
+ if (
28
+ typeof jest !== 'undefined' &&
29
+ defaultFs.existsSync &&
30
+ typeof jest.isMockFunction === 'function' &&
31
+ jest.isMockFunction(defaultFs.existsSync)
32
+ ) {
33
+ return defaultFs;
34
+ }
35
+ return realFs;
36
+ }
16
37
 
17
38
  /**
18
39
  * Resolves path to application config file (application.yaml, application.json, or legacy variables.yaml).
@@ -23,6 +44,7 @@ const fs = require('fs');
23
44
  * @throws {Error} If no config file found
24
45
  */
25
46
  function resolveApplicationConfigPath(appPath) {
47
+ const fs = getResolverFs();
26
48
  if (!appPath || typeof appPath !== 'string') {
27
49
  throw new Error('App path is required and must be a string');
28
50
  }
@@ -59,6 +81,7 @@ const RBAC_NAMES = ['rbac.yaml', 'rbac.yml', 'rbac.json'];
59
81
  * @returns {string|null} Absolute path to RBAC file, or null if none exist
60
82
  */
61
83
  function resolveRbacPath(appPath) {
84
+ const fs = getResolverFs();
62
85
  if (!appPath || typeof appPath !== 'string') {
63
86
  throw new Error('App path is required and must be a string');
64
87
  }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * User `applications` entries in config.yaml (read by `aifabrix resolve`, written by `aifabrix run` dev).
3
+ *
4
+ * @fileoverview Per-app reload and `proxy` (Traefik/public URL hints) in ~/.aifabrix/config.yaml
5
+ * @author AI Fabrix Team
6
+ * @version 1.0.0
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const config = require('../core/config');
12
+
13
+ /**
14
+ * Persist `applications.<appKey>.reload` from the last `aifabrix run` (dev only; CLI is source of truth).
15
+ *
16
+ * @async
17
+ * @param {string} appKey - Application key
18
+ * @param {boolean} reload - Same as passing `--reload` on run
19
+ * @returns {Promise<void>}
20
+ */
21
+ async function persistApplicationReloadFlag(appKey, reload) {
22
+ if (!appKey || typeof appKey !== 'string') {
23
+ return;
24
+ }
25
+ const cfg = await config.getConfig();
26
+ if (!cfg.applications || typeof cfg.applications !== 'object') {
27
+ cfg.applications = {};
28
+ }
29
+ const prev = cfg.applications[appKey];
30
+ const nextEntry =
31
+ prev && typeof prev === 'object' && !Array.isArray(prev) ? { ...prev, reload: Boolean(reload) } : { reload: Boolean(reload) };
32
+ cfg.applications[appKey] = nextEntry;
33
+ await config.saveConfig(cfg);
34
+ }
35
+
36
+ /**
37
+ * True when `applications.<appKey>.reload` is explicitly **true** (bind-mount + hot reload for `aifabrix run`).
38
+ * Does **not** control other compose overrides (e.g. Python image `command` when the image lacks `uvicorn` on PATH).
39
+ *
40
+ * @param {Object|null|undefined} userConfig - Parsed config.yaml root
41
+ * @param {string} appKey - Application key (folder / app.key)
42
+ * @returns {boolean} True when `applications.<appKey>.reload` is explicitly true
43
+ */
44
+ function isApplicationsReloadDefaultOn(userConfig, appKey) {
45
+ if (!userConfig || !appKey || typeof appKey !== 'string') {
46
+ return false;
47
+ }
48
+ const apps = userConfig.applications;
49
+ if (!apps || typeof apps !== 'object') {
50
+ return false;
51
+ }
52
+ const entry = apps[appKey];
53
+ if (!entry || typeof entry !== 'object') {
54
+ return false;
55
+ }
56
+ return entry.reload === true;
57
+ }
58
+
59
+ /**
60
+ * True when `remote-server` is a non-empty string (after trim).
61
+ *
62
+ * @param {string|null|undefined} remoteServer
63
+ * @returns {boolean}
64
+ */
65
+ function isRemoteServerConfigured(remoteServer) {
66
+ return Boolean(remoteServer && String(remoteServer).trim());
67
+ }
68
+
69
+ /**
70
+ * Whether `build.envOutputPath` should be regenerated with the **local** profile (`generateEnvContent(..., 'local')`).
71
+ * **False** only when **both** `remote-server` is set and `applications.<appKey>.reload` is true — then the host file
72
+ * uses the same **docker**-flavor expansion as `builder/<app>/.env` (copy path). All other cases use **local**.
73
+ *
74
+ * @param {Object|null|undefined} userConfig - Parsed config.yaml root
75
+ * @param {string} appKey - Application key
76
+ * @param {string|null|undefined} remoteServer - `remote-server` value (or null)
77
+ * @returns {boolean}
78
+ */
79
+ function isPreferLocalEnvOutputPath(userConfig, appKey, remoteServer) {
80
+ if (!isRemoteServerConfigured(remoteServer)) {
81
+ return true;
82
+ }
83
+ return !isApplicationsReloadDefaultOn(userConfig, appKey);
84
+ }
85
+
86
+ /**
87
+ * For `aifabrix resolve`: reads `remote-server` and returns {@link isPreferLocalEnvOutputPath}.
88
+ *
89
+ * @async
90
+ * @param {Object|null|undefined} userConfig
91
+ * @param {string} appKey
92
+ * @returns {Promise<boolean>}
93
+ */
94
+ async function resolvePreferLocalEnvOutputPathFlag(userConfig, appKey) {
95
+ let remoteServer = null;
96
+ try {
97
+ const rs = await config.getRemoteServer();
98
+ if (rs && String(rs).trim()) {
99
+ remoteServer = String(rs).trim();
100
+ }
101
+ } catch {
102
+ remoteServer = null;
103
+ }
104
+ return isPreferLocalEnvOutputPath(userConfig, appKey, remoteServer);
105
+ }
106
+
107
+ /**
108
+ * Normalize YAML boolean-ish values.
109
+ * @param {unknown} v
110
+ * @returns {boolean|undefined} undefined if absent / not coercible
111
+ */
112
+ function coerceTriStateBool(v) {
113
+ if (v === true || v === 'true' || v === 'yes' || v === 'on' || v === 1 || v === '1') {
114
+ return true;
115
+ }
116
+ if (v === false || v === 'false' || v === 'no' || v === 'off' || v === 0 || v === '0') {
117
+ return false;
118
+ }
119
+ return undefined;
120
+ }
121
+
122
+ /**
123
+ * Whether `aifabrix run` / resolve should use Traefik-style public URL hints for this app when infra allows.
124
+ * Default **false** when unset. Legacy `noProxy: true` ⇒ false; legacy `noProxy: false` with no `proxy` ⇒ true.
125
+ *
126
+ * @param {Object|null|undefined} userConfig - Parsed config.yaml root
127
+ * @param {string} appKey - Application key
128
+ * @returns {boolean}
129
+ */
130
+ function getApplicationsRunProxyHint(userConfig, appKey) {
131
+ if (!userConfig || !appKey || typeof appKey !== 'string') {
132
+ return false;
133
+ }
134
+ const apps = userConfig.applications;
135
+ if (!apps || typeof apps !== 'object') {
136
+ return false;
137
+ }
138
+ const entry = apps[appKey];
139
+ if (!entry || typeof entry !== 'object') {
140
+ return false;
141
+ }
142
+ const proxyCoerced = coerceTriStateBool(entry.proxy);
143
+ if (proxyCoerced !== undefined) {
144
+ return proxyCoerced;
145
+ }
146
+ const legacyNo = coerceTriStateBool(entry.noProxy);
147
+ if (legacyNo === true) {
148
+ return false;
149
+ }
150
+ if (legacyNo === false) {
151
+ return true;
152
+ }
153
+ return false;
154
+ }
155
+
156
+ /**
157
+ * Persist `applications.<appKey>.proxy` from each `aifabrix run` (same pattern as `reload` for dev-only reload flag).
158
+ * `proxy: true` means use Traefik/front-door URL hints when infra + that app's `application.yaml` allow; `false` means docker `url://` **public** bases for **that app** use localhost + published port. During `resolve`/`run`, each `url://` token uses the **target** app's `applications.<targetKey>.proxy`, not only the app whose `env.template` is being expanded.
159
+ * Removes legacy `noProxy` on the same entry when present.
160
+ *
161
+ * @async
162
+ * @param {string} appKey - Application key
163
+ * @param {boolean} proxyEnabled - Same as a normal `aifabrix run` with proxy hints on (`!isRunCliNoProxy(options)`)
164
+ * @returns {Promise<void>}
165
+ */
166
+ async function persistApplicationRunProxyFlag(appKey, proxyEnabled) {
167
+ if (!appKey || typeof appKey !== 'string') {
168
+ return;
169
+ }
170
+ const cfg = await config.getConfig();
171
+ if (!cfg.applications || typeof cfg.applications !== 'object') {
172
+ cfg.applications = {};
173
+ }
174
+ const prev = cfg.applications[appKey];
175
+ const nextEntry =
176
+ prev && typeof prev === 'object' && !Array.isArray(prev)
177
+ ? { ...prev, proxy: Boolean(proxyEnabled) }
178
+ : { proxy: Boolean(proxyEnabled) };
179
+ if (Object.prototype.hasOwnProperty.call(nextEntry, 'noProxy')) {
180
+ delete nextEntry.noProxy;
181
+ }
182
+ cfg.applications[appKey] = nextEntry;
183
+ await config.saveConfig(cfg);
184
+ }
185
+
186
+ /**
187
+ * Whether declarative `url://*` expansion should treat Traefik as on (infra Traefik + per-app `proxy: true`).
188
+ *
189
+ * @param {Object|null|undefined} userConfig
190
+ * @param {string} appKey
191
+ * @returns {boolean}
192
+ */
193
+ function isDeclarativeTraefikUrlsEnabled(userConfig, appKey) {
194
+ return Boolean(userConfig && userConfig.traefik === true) && getApplicationsRunProxyHint(userConfig, appKey);
195
+ }
196
+
197
+ module.exports = {
198
+ getApplicationsRunProxyHint,
199
+ isApplicationsReloadDefaultOn,
200
+ isDeclarativeTraefikUrlsEnabled,
201
+ isPreferLocalEnvOutputPath,
202
+ isRemoteServerConfigured,
203
+ persistApplicationRunProxyFlag,
204
+ persistApplicationReloadFlag,
205
+ resolvePreferLocalEnvOutputPathFlag
206
+ };
@@ -8,7 +8,7 @@
8
8
  * @version 2.0.0
9
9
  */
10
10
 
11
- const { getControllerUrlFromLoggedInUser } = require('./controller-url');
11
+ const { hasStoredDeviceTokenForController } = require('./controller-url');
12
12
 
13
13
  /**
14
14
  * Validate controller URL format
@@ -72,17 +72,7 @@ async function checkUserLoggedIn(controllerUrl) {
72
72
  if (!controllerUrl) {
73
73
  return false;
74
74
  }
75
-
76
- const normalizedUrl = controllerUrl.trim().replace(/\/+$/, '');
77
- const loggedInControllerUrl = await getControllerUrlFromLoggedInUser();
78
-
79
- if (!loggedInControllerUrl) {
80
- return false;
81
- }
82
-
83
- // Normalize both URLs for comparison
84
- const normalizedLoggedIn = loggedInControllerUrl.trim().replace(/\/+$/, '');
85
- return normalizedLoggedIn === normalizedUrl;
75
+ return hasStoredDeviceTokenForController(controllerUrl);
86
76
  }
87
77
 
88
78
  module.exports = {
@@ -41,7 +41,7 @@ function collectBashPrefixedEnv(secrets) {
41
41
  }
42
42
 
43
43
  /**
44
- * Overlay for `child_process` `env`: values from user + shared + ancestor `secrets.local.yaml`
44
+ * Overlay for `child_process` `env`: values from primary user `secrets.local.yaml` plus `aifabrix-secrets`
45
45
  * for every `BASH_*` key (merged via {@link secretsLoad.loadSecrets}).
46
46
  *
47
47
  * @param {string|null} [secretsPath] - Optional explicit secrets file (same as resolve)