@aifabrix/builder 2.33.1 → 2.33.4

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 (42) hide show
  1. package/README.md +13 -0
  2. package/lib/app/deploy-config.js +161 -0
  3. package/lib/app/deploy.js +28 -153
  4. package/lib/app/register.js +6 -5
  5. package/lib/app/run-helpers.js +23 -17
  6. package/lib/cli.js +31 -1
  7. package/lib/commands/logout.js +3 -4
  8. package/lib/commands/up-common.js +72 -0
  9. package/lib/commands/up-dataplane.js +109 -0
  10. package/lib/commands/up-miso.js +134 -0
  11. package/lib/core/config.js +32 -9
  12. package/lib/core/secrets-docker-env.js +88 -0
  13. package/lib/core/secrets.js +142 -115
  14. package/lib/infrastructure/helpers.js +82 -1
  15. package/lib/infrastructure/index.js +2 -0
  16. package/lib/schema/env-config.yaml +7 -0
  17. package/lib/utils/compose-generator.js +13 -13
  18. package/lib/utils/config-paths.js +13 -0
  19. package/lib/utils/device-code.js +2 -2
  20. package/lib/utils/env-endpoints.js +2 -5
  21. package/lib/utils/env-map.js +18 -14
  22. package/lib/utils/parse-image-ref.js +27 -0
  23. package/lib/utils/paths.js +28 -4
  24. package/lib/utils/secrets-generator.js +34 -12
  25. package/lib/utils/secrets-helpers.js +1 -2
  26. package/lib/utils/token-manager-refresh.js +5 -0
  27. package/package.json +1 -1
  28. package/templates/applications/dataplane/Dockerfile +16 -0
  29. package/templates/applications/dataplane/README.md +205 -0
  30. package/templates/applications/dataplane/env.template +143 -0
  31. package/templates/applications/dataplane/rbac.yaml +283 -0
  32. package/templates/applications/dataplane/variables.yaml +143 -0
  33. package/templates/applications/keycloak/Dockerfile +1 -1
  34. package/templates/applications/keycloak/README.md +193 -0
  35. package/templates/applications/keycloak/variables.yaml +5 -6
  36. package/templates/applications/miso-controller/Dockerfile +8 -8
  37. package/templates/applications/miso-controller/README.md +369 -0
  38. package/templates/applications/miso-controller/env.template +114 -6
  39. package/templates/applications/miso-controller/rbac.yaml +74 -0
  40. package/templates/applications/miso-controller/variables.yaml +93 -5
  41. package/templates/infra/compose.yaml.hbs +2 -1
  42. package/templates/applications/miso-controller/test.yaml +0 -1
@@ -11,7 +11,6 @@
11
11
 
12
12
  const fs = require('fs');
13
13
  const path = require('path');
14
- const yaml = require('js-yaml');
15
14
  const logger = require('../utils/logger');
16
15
  const config = require('./config');
17
16
  const {
@@ -30,6 +29,7 @@ const {
30
29
  const { processEnvVariables } = require('../utils/env-copy');
31
30
  const { buildEnvVarMap } = require('../utils/env-map');
32
31
  const { resolveServicePortsInEnvContent } = require('../utils/secrets-url');
32
+ const { updatePortForDocker } = require('./secrets-docker-env');
33
33
  const {
34
34
  generateMissingSecrets,
35
35
  createDefaultSecrets
@@ -44,7 +44,6 @@ const {
44
44
  } = require('../utils/secrets-utils');
45
45
  const { decryptSecret, isEncrypted } = require('../utils/secrets-encryption');
46
46
  const pathsUtil = require('../utils/paths');
47
- const { getContainerPortFromPath } = require('../utils/port-resolver');
48
47
 
49
48
  /**
50
49
  * Generates a canonical secret name from an environment variable key.
@@ -113,7 +112,7 @@ async function decryptSecretsObject(secrets) {
113
112
  /**
114
113
  * Loads secrets with cascading lookup
115
114
  * Supports both user secrets (~/.aifabrix/secrets.local.yaml) and project overrides
116
- * User's file takes priority, then falls back to aifabrix-secrets from config.yaml
115
+ * When aifabrix-secrets (or secrets-path) is set in config.yaml and that file exists, it is used as base; user's file (local) is strongest and overrides project for same key. Otherwise user's file first, then aifabrix-secrets as fallback.
117
116
  * Automatically decrypts values with secure:// prefix
118
117
  *
119
118
  * @async
@@ -126,9 +125,40 @@ async function decryptSecretsObject(secrets) {
126
125
  * @example
127
126
  * const secrets = await loadSecrets('../../secrets.local.yaml');
128
127
  * // Returns: { 'postgres-passwordKeyVault': 'admin123', ... }
128
+ *
129
+ * @example
130
+ * // When config.yaml has aifabrix-secrets: ./secrets.local.yaml, project file is base;
131
+ * // ~/.aifabrix/secrets.local.yaml overrides project for same key (local strongest).
132
+ * const secrets = await loadSecrets(undefined, 'myapp');
133
+ */
134
+
135
+ /**
136
+ * Loads config secrets path, merges with user secrets (user overrides). Used by loadSecrets cascade.
137
+ * @async
138
+ * @returns {Promise<Object|null>} Merged secrets object or null
129
139
  */
140
+ async function loadMergedConfigAndUserSecrets() {
141
+ try {
142
+ const configSecretsPath = await config.getSecretsPath();
143
+ if (!configSecretsPath) return null;
144
+ const resolvedConfigPath = path.isAbsolute(configSecretsPath)
145
+ ? configSecretsPath
146
+ : path.resolve(process.cwd(), configSecretsPath);
147
+ if (!fs.existsSync(resolvedConfigPath)) return null;
148
+ const configSecrets = readYamlAtPath(resolvedConfigPath);
149
+ if (!configSecrets || typeof configSecrets !== 'object') return null;
150
+ const merged = { ...configSecrets };
151
+ const userSecrets = loadUserSecrets();
152
+ for (const key of Object.keys(userSecrets)) {
153
+ merged[key] = userSecrets[key];
154
+ }
155
+ return merged;
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
130
161
  async function loadSecrets(secretsPath, _appName) {
131
- // Explicit path branch
132
162
  if (secretsPath) {
133
163
  const resolvedPath = resolveSecretsPath(secretsPath);
134
164
  if (!fs.existsSync(resolvedPath)) {
@@ -141,9 +171,11 @@ async function loadSecrets(secretsPath, _appName) {
141
171
  return await decryptSecretsObject(explicitSecrets);
142
172
  }
143
173
 
144
- // Cascading lookup branch
145
- let mergedSecrets = loadUserSecrets();
146
- mergedSecrets = await applyCanonicalSecretsOverride(mergedSecrets);
174
+ let mergedSecrets = await loadMergedConfigAndUserSecrets();
175
+ if (!mergedSecrets || Object.keys(mergedSecrets).length === 0) {
176
+ mergedSecrets = loadUserSecrets();
177
+ mergedSecrets = await applyCanonicalSecretsOverride(mergedSecrets);
178
+ }
147
179
  if (Object.keys(mergedSecrets).length === 0) {
148
180
  mergedSecrets = loadDefaultSecrets();
149
181
  }
@@ -174,14 +206,12 @@ async function loadSecrets(secretsPath, _appName) {
174
206
  async function resolveKvReferences(envTemplate, secrets, environment = 'local', secretsFilePaths = null, appName = null) {
175
207
  const os = require('os');
176
208
 
177
- // Get developer-id for local environment to adjust port variables
209
+ // Get developer-id for port variables (local and docker: *_PUBLIC_PORT = base + devId*100)
178
210
  let developerId = null;
179
- if (environment === 'local') {
180
- try {
181
- developerId = await config.getDeveloperId();
182
- } catch {
183
- // ignore, will use null (buildEnvVarMap will fetch it)
184
- }
211
+ try {
212
+ developerId = await config.getDeveloperId();
213
+ } catch {
214
+ // ignore, buildEnvVarMap will use default
185
215
  }
186
216
 
187
217
  let envVars = await buildEnvVarMap(environment, os, developerId);
@@ -199,103 +229,6 @@ async function resolveKvReferences(envTemplate, secrets, environment = 'local',
199
229
  return replaceKvInContent(resolved, secrets, envVars);
200
230
  }
201
231
 
202
- // resolveServicePortsInEnvContent, loadEnvTemplate, and processEnvVariables
203
- // are imported from ./utils/secrets-helpers above.
204
-
205
- /**
206
- * Generates .env file from template and secrets
207
- * Creates environment file for local development
208
- *
209
- * @async
210
- * @function generateEnvFile
211
- * @param {string} appName - Name of the application
212
- * @param {string} [secretsPath] - Path to secrets file (optional)
213
- * @param {string} [environment='local'] - Environment context
214
- * @param {boolean} [force=false] - Generate missing secret keys in secrets file
215
- * @param {boolean} [skipOutputPath=false] - Skip processing envOutputPath (to avoid recursion)
216
- * @returns {Promise<string>} Path to generated .env file
217
- * @throws {Error} If generation fails
218
- *
219
- * @example
220
- * const envPath = await generateEnvFile('myapp', '../../secrets.local.yaml', 'local', true);
221
- * // Returns: './builder/myapp/.env'
222
- */
223
- /**
224
- * Gets base docker environment config
225
- * @async
226
- * @function getBaseDockerEnv
227
- * @returns {Promise<Object>} Docker environment config
228
- */
229
- async function getBaseDockerEnv() {
230
- const { getEnvHosts } = require('../utils/env-endpoints');
231
- return await getEnvHosts('docker');
232
- }
233
-
234
- /**
235
- * Applies config.yaml override to docker environment
236
- * @function applyDockerEnvOverride
237
- * @param {Object} dockerEnv - Base docker environment config
238
- * @returns {Object} Updated docker environment config
239
- */
240
- function applyDockerEnvOverride(dockerEnv) {
241
- try {
242
- const os = require('os');
243
- const cfgPath = path.join(os.homedir(), '.aifabrix', 'config.yaml');
244
- if (fs.existsSync(cfgPath)) {
245
- const cfgContent = fs.readFileSync(cfgPath, 'utf8');
246
- const cfg = yaml.load(cfgContent) || {};
247
- if (cfg && cfg.environments && cfg.environments.docker) {
248
- return { ...dockerEnv, ...cfg.environments.docker };
249
- }
250
- }
251
- } catch {
252
- // Ignore config.yaml read errors, continue with env-config values
253
- }
254
- return dockerEnv;
255
- }
256
-
257
- /**
258
- * Gets container port from docker environment config
259
- * @function getContainerPortFromDockerEnv
260
- * @param {Object} dockerEnv - Docker environment config
261
- * @returns {number} Container port (defaults to 3000)
262
- */
263
- function getContainerPortFromDockerEnv(dockerEnv) {
264
- if (dockerEnv.PORT === undefined || dockerEnv.PORT === null) {
265
- return 3000;
266
- }
267
- const portVal = typeof dockerEnv.PORT === 'number' ? dockerEnv.PORT : parseInt(dockerEnv.PORT, 10);
268
- return Number.isNaN(portVal) ? 3000 : portVal;
269
- }
270
-
271
- /**
272
- * Updates PORT in resolved content for docker environment
273
- * Sets PORT to container port (build.containerPort or port from variables.yaml)
274
- * NOT the host port (which includes developer-id offset)
275
- * @async
276
- * @function updatePortForDocker
277
- * @param {string} resolved - Resolved environment content
278
- * @param {string} variablesPath - Path to variables.yaml file
279
- * @returns {Promise<string>} Updated content with PORT set
280
- */
281
- async function updatePortForDocker(resolved, variablesPath) {
282
- // Step 1: Get base config from env-config.yaml
283
- let dockerEnv = await getBaseDockerEnv();
284
-
285
- // Step 2: Apply config.yaml → environments.docker override (if exists)
286
- dockerEnv = applyDockerEnvOverride(dockerEnv);
287
-
288
- // Step 3: Get PORT value for container (should be container port, NOT host port)
289
- let containerPort = getContainerPortFromPath(variablesPath);
290
- if (containerPort === null) {
291
- containerPort = getContainerPortFromDockerEnv(dockerEnv);
292
- }
293
-
294
- // PORT in container should be the container port (no developer-id adjustment)
295
- // Docker will map container port to host port via port mapping
296
- return resolved.replace(/^PORT\s*=\s*.*$/m, `PORT=${containerPort}`);
297
- }
298
-
299
232
  /**
300
233
  * Applies environment-specific transformations to resolved content
301
234
  * @async
@@ -348,7 +281,7 @@ async function applyEnvironmentTransformations(resolved, environment, variablesP
348
281
  * @throws {Error} If generation fails
349
282
  */
350
283
  async function generateEnvContent(appName, secretsPath, environment = 'local', force = false) {
351
- const builderPath = path.join(process.cwd(), 'builder', appName);
284
+ const builderPath = pathsUtil.getBuilderPath(appName);
352
285
  const templatePath = path.join(builderPath, 'env.template');
353
286
  const variablesPath = path.join(builderPath, 'variables.yaml');
354
287
 
@@ -375,14 +308,108 @@ async function generateEnvContent(appName, secretsPath, environment = 'local', f
375
308
  return resolved;
376
309
  }
377
310
 
378
- async function generateEnvFile(appName, secretsPath, environment = 'local', force = false, skipOutputPath = false) {
379
- const builderPath = path.join(process.cwd(), 'builder', appName);
311
+ /**
312
+ * Parses .env file content into a key-to-value map.
313
+ * Only includes lines that look like KEY=value (first = separates key and value).
314
+ *
315
+ * @function parseEnvContentToMap
316
+ * @param {string} content - Raw .env file content
317
+ * @returns {Object.<string, string>} Map of variable name to value
318
+ */
319
+ function parseEnvContentToMap(content) {
320
+ if (!content || typeof content !== 'string') {
321
+ return {};
322
+ }
323
+ const map = {};
324
+ const lines = content.split(/\r?\n/);
325
+ for (const line of lines) {
326
+ const trimmed = line.trim();
327
+ if (!trimmed || trimmed.startsWith('#')) {
328
+ continue;
329
+ }
330
+ const eq = trimmed.indexOf('=');
331
+ if (eq > 0) {
332
+ const key = trimmed.substring(0, eq).trim();
333
+ const value = trimmed.substring(eq + 1);
334
+ map[key] = value;
335
+ }
336
+ }
337
+ return map;
338
+ }
339
+
340
+ /**
341
+ * Merges new .env content with existing .env: newly resolved content wins for keys it
342
+ * defines (so project secrets take effect when re-running). Keys only in existing .env
343
+ * are appended so manual additions are kept.
344
+ *
345
+ * @function mergeEnvContentPreservingExisting
346
+ * @param {string} newContent - Newly generated .env content (from template + loadSecrets)
347
+ * @param {Object.<string, string>} existingMap - Existing key-to-value map from current .env
348
+ * @returns {string} Merged content: new values for keys in newContent, plus extra existing keys
349
+ */
350
+ function mergeEnvContentPreservingExisting(newContent, existingMap) {
351
+ const lines = newContent.split(/\r?\n/);
352
+ const newKeys = new Set();
353
+ const out = [];
354
+ for (const line of lines) {
355
+ const trimmed = line.trim();
356
+ if (!trimmed || trimmed.startsWith('#')) {
357
+ out.push(line);
358
+ continue;
359
+ }
360
+ const eq = trimmed.indexOf('=');
361
+ if (eq > 0) {
362
+ const key = trimmed.substring(0, eq).trim();
363
+ newKeys.add(key);
364
+ }
365
+ out.push(line);
366
+ }
367
+ if (existingMap && Object.keys(existingMap).length > 0) {
368
+ for (const key of Object.keys(existingMap)) {
369
+ if (!newKeys.has(key)) {
370
+ out.push(`${key}=${existingMap[key]}`);
371
+ }
372
+ }
373
+ }
374
+ return out.join('\n');
375
+ }
376
+
377
+ /**
378
+ * Generates and writes .env file. Newly resolved values (from template + secrets) win over
379
+ * existing .env so that project secrets (e.g. from config aifabrix-secrets) take effect on
380
+ * re-run. Extra variables only in existing .env are kept.
381
+ *
382
+ * @async
383
+ * @function generateEnvFile
384
+ * @param {string} appName - Name of the application
385
+ * @param {string} [secretsPath] - Path to secrets file (optional)
386
+ * @param {string} [environment='local'] - Environment context ('local' or 'docker')
387
+ * @param {boolean} [force=false] - Generate missing secret keys in secrets file
388
+ * @param {boolean} [skipOutputPath=false] - Skip copying to envOutputPath
389
+ * @param {string} [preserveFromPath=null] - Path to existing .env to preserve values from (defaults to builder .env)
390
+ * @returns {Promise<string>} Path to generated .env file
391
+ * @throws {Error} If generation fails
392
+ *
393
+ * @example
394
+ * const envPath = await generateEnvFile('myapp', undefined, 'docker');
395
+ * // When builder/myapp/.env already exists, existing values are preserved
396
+ */
397
+ async function generateEnvFile(appName, secretsPath, environment = 'local', force = false, skipOutputPath = false, preserveFromPath = null) {
398
+ const builderPath = pathsUtil.getBuilderPath(appName);
380
399
  const variablesPath = path.join(builderPath, 'variables.yaml');
381
400
  const envPath = path.join(builderPath, '.env');
382
401
 
383
402
  const resolved = await generateEnvContent(appName, secretsPath, environment, force);
384
403
 
385
- fs.writeFileSync(envPath, resolved, { mode: 0o600 });
404
+ let toWrite = resolved;
405
+ const pathToPreserve = preserveFromPath || envPath;
406
+ if (pathToPreserve && fs.existsSync(pathToPreserve)) {
407
+ const existingContent = fs.readFileSync(pathToPreserve, 'utf8');
408
+ const existingMap = parseEnvContentToMap(existingContent);
409
+ toWrite = mergeEnvContentPreservingExisting(resolved, existingMap);
410
+ }
411
+
412
+ fs.writeFileSync(envPath, toWrite, { mode: 0o600 });
386
413
 
387
414
  // Process and copy to envOutputPath if configured (uses localPort for copied file)
388
415
  if (!skipOutputPath) {
@@ -73,7 +73,7 @@ async function ensureAdminSecrets() {
73
73
  * @param {string} postgresPassword - PostgreSQL password
74
74
  */
75
75
  function generatePgAdminConfig(infraDir, postgresPassword) {
76
- const serversJsonTemplatePath = path.join(__dirname, '..', 'templates', 'infra', 'servers.json.hbs');
76
+ const serversJsonTemplatePath = path.join(__dirname, '..', '..', 'templates', 'infra', 'servers.json.hbs');
77
77
  if (!fs.existsSync(serversJsonTemplatePath)) {
78
78
  return;
79
79
  }
@@ -89,6 +89,86 @@ function generatePgAdminConfig(infraDir, postgresPassword) {
89
89
  fs.writeFileSync(pgpassPath, pgpassContent, { mode: 0o600 });
90
90
  }
91
91
 
92
+ /**
93
+ * Escape single quotes for use in PostgreSQL string literal (double each single quote)
94
+ * @param {string} s - Raw string
95
+ * @returns {string} Escaped string
96
+ */
97
+ function escapePgString(s) {
98
+ if (s === null || s === undefined || typeof s !== 'string') return '';
99
+ return s.replace(/'/g, '\'\'');
100
+ }
101
+
102
+ /**
103
+ * Extract password from a URL string or plain password value
104
+ * @param {string} urlOrPassword - URL (with ://) or plain password
105
+ * @returns {string|null} Extracted password or null to keep default
106
+ */
107
+ function extractPasswordFromUrlOrValue(urlOrPassword) {
108
+ if (typeof urlOrPassword !== 'string' || urlOrPassword.length === 0) return null;
109
+ if (!urlOrPassword.includes('://')) return urlOrPassword;
110
+ try {
111
+ const u = new URL(urlOrPassword.replace(/\$\{DB_HOST\}/g, 'postgres').replace(/\$\{DB_PORT\}/g, '5432'));
112
+ return u.password || null;
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Ensures Postgres init script exists for miso-controller app (database miso, user miso_user).
120
+ * Uses password from secrets (databases-miso-controller-0-passwordKeyVault) or default miso_pass123.
121
+ * Init scripts run only when the Postgres data volume is first created. If infra was already
122
+ * started before this script existed, run `aifabrix down -v` then `aifabrix up` to re-init, or
123
+ * create the database and user manually (e.g. via pgAdmin or psql).
124
+ *
125
+ * @async
126
+ * @param {string} infraDir - Infrastructure directory path
127
+ * @returns {Promise<void>}
128
+ */
129
+ async function ensureMisoInitScript(infraDir) {
130
+ const initScriptsDir = path.join(infraDir, 'init-scripts');
131
+ if (!fs.existsSync(initScriptsDir)) {
132
+ fs.mkdirSync(initScriptsDir, { recursive: true });
133
+ }
134
+
135
+ let password = 'miso_pass123';
136
+ try {
137
+ const loaded = await secrets.loadSecrets(undefined);
138
+ const urlOrPassword = loaded['databases-miso-controller-0-passwordKeyVault'] ||
139
+ loaded['databases-miso-controller-0-urlKeyVault'];
140
+ const extracted = extractPasswordFromUrlOrValue(urlOrPassword);
141
+ if (extracted !== null) password = extracted;
142
+ } catch {
143
+ // No secrets or load failed; use default
144
+ }
145
+
146
+ const passwordEscaped = escapePgString(password);
147
+
148
+ const sh = `#!/bin/bash
149
+ set -e
150
+ # Miso-controller app database and user (matches DATABASE_URL in env)
151
+ # Runs on first Postgres volume init only
152
+ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<EOSQL
153
+ DO \\$\\$
154
+ BEGIN
155
+ IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'miso_user') THEN
156
+ CREATE USER miso_user WITH PASSWORD '${passwordEscaped}';
157
+ ELSE
158
+ ALTER USER miso_user WITH PASSWORD '${passwordEscaped}';
159
+ END IF;
160
+ END
161
+ \\$\\$;
162
+ EOSQL
163
+ if ! psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -tAc "SELECT 1 FROM pg_database WHERE datname = 'miso'" | grep -q 1; then
164
+ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -c "CREATE DATABASE miso OWNER miso_user;"
165
+ fi
166
+ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "miso" -c "GRANT ALL ON SCHEMA public TO miso_user; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO miso_user;"
167
+ `;
168
+ const initPath = path.join(initScriptsDir, '02-miso-app.sh');
169
+ fs.writeFileSync(initPath, sh, { mode: 0o755 });
170
+ }
171
+
92
172
  /**
93
173
  * Prepare infrastructure directory and extract postgres password
94
174
  * @param {string} devId - Developer ID
@@ -135,5 +215,6 @@ module.exports = {
135
215
  ensureAdminSecrets,
136
216
  generatePgAdminConfig,
137
217
  prepareInfraDirectory,
218
+ ensureMisoInitScript,
138
219
  registerHandlebarsHelper
139
220
  };
@@ -23,6 +23,7 @@ const {
23
23
  checkDockerAvailability,
24
24
  ensureAdminSecrets,
25
25
  prepareInfraDirectory,
26
+ ensureMisoInitScript,
26
27
  registerHandlebarsHelper
27
28
  } = require('./helpers');
28
29
  const {
@@ -59,6 +60,7 @@ async function prepareInfrastructureEnvironment(developerId) {
59
60
 
60
61
  // Prepare infrastructure directory
61
62
  const { infraDir } = prepareInfraDirectory(devId, adminSecretsPath);
63
+ await ensureMisoInitScript(infraDir);
62
64
 
63
65
  return { devId, idNum, ports, templatePath, infraDir, adminSecretsPath };
64
66
  }
@@ -18,6 +18,9 @@ environments:
18
18
  MISO_PORT: 3000 # Internal port (container-to-container). MISO_PUBLIC_PORT calculated automatically.
19
19
  KEYCLOAK_HOST: keycloak
20
20
  KEYCLOAK_PORT: 8082 # Internal port (container-to-container). KEYCLOAK_PUBLIC_PORT calculated automatically.
21
+ KEYCLOAK_PUBLIC_PORT: 8082
22
+ DATAPLANE_HOST: dataplane
23
+ DATAPLANE_PORT: 3001
21
24
  NODE_ENV: production
22
25
  PYTHONUNBUFFERED: 1
23
26
  PYTHONDONTWRITEBYTECODE: 1
@@ -30,8 +33,12 @@ environments:
30
33
  REDIS_PORT: 6379
31
34
  MISO_HOST: localhost
32
35
  MISO_PORT: 3010
36
+ MISO_PUBLIC_PORT: 3010
33
37
  KEYCLOAK_HOST: localhost
34
38
  KEYCLOAK_PORT: 8082
39
+ KEYCLOAK_PUBLIC_PORT: 8082
40
+ DATAPLANE_HOST: localhost
41
+ DATAPLANE_PORT: 3011
35
42
  NODE_ENV: development
36
43
  PYTHONUNBUFFERED: 1
37
44
  PYTHONDONTWRITEBYTECODE: 1
@@ -17,6 +17,7 @@ const config = require('../core/config');
17
17
  const buildCopy = require('./build-copy');
18
18
  const { formatMissingDbPasswordError } = require('./error-formatter');
19
19
  const { getContainerPort } = require('./port-resolver');
20
+ const { parseImageOverride } = require('./parse-image-ref');
20
21
 
21
22
  // Register commonly used helpers
22
23
  handlebars.registerHelper('eq', (a, b) => a === b);
@@ -129,9 +130,14 @@ function buildAppConfig(appName, config) {
129
130
  * Builds image configuration section
130
131
  * @param {Object} config - Application configuration
131
132
  * @param {string} appName - Application name
133
+ * @param {string} [imageOverride] - Optional full image reference (registry/name:tag) to use instead of config
132
134
  * @returns {Object} Image configuration
133
135
  */
134
- function buildImageConfig(config, appName) {
136
+ function buildImageConfig(config, appName, imageOverride) {
137
+ const parsed = imageOverride ? parseImageOverride(imageOverride) : null;
138
+ if (parsed) {
139
+ return { name: parsed.name, tag: parsed.tag };
140
+ }
135
141
  const imageName = getImageName(config, appName);
136
142
  const imageTag = config.image?.tag || 'latest';
137
143
  return {
@@ -237,14 +243,15 @@ function buildRequiresConfig(config) {
237
243
  * @param {Object} config - Application configuration
238
244
  * @param {number} port - Application port
239
245
  * @param {string|number} devId - Developer ID
246
+ * @param {string} [imageOverride] - Optional full image reference for run (e.g. from --image)
240
247
  * @returns {Object} Service configuration
241
248
  */
242
- function buildServiceConfig(appName, config, port, devId) {
249
+ function buildServiceConfig(appName, config, port, devId, imageOverride) {
243
250
  const containerPortValue = getContainerPort(config, 3000);
244
251
  const hostPort = port;
245
252
  return {
246
253
  app: buildAppConfig(appName, config),
247
- image: buildImageConfig(config, appName),
254
+ image: buildImageConfig(config, appName, imageOverride),
248
255
  port: containerPortValue, // Container port (for health check and template)
249
256
  containerPort: containerPortValue, // Container port (always set, equals containerPort if exists, else port)
250
257
  hostPort: hostPort, // Host port (options.port if provided, else config.port)
@@ -275,14 +282,7 @@ function buildNetworksConfig(config) {
275
282
  return { databases: config.requires?.databases || config.databases || [] };
276
283
  }
277
284
 
278
- /**
279
- * Reads and parses .env file
280
- * @async
281
- * @function readEnvFile
282
- * @param {string} envPath - Path to .env file
283
- * @returns {Promise<Object>} Object with environment variables
284
- * @throws {Error} If file not found or read fails
285
- */
285
+ /** Reads and parses .env file. @param {string} envPath - Path to .env file. @returns {Promise<Object>} env vars. @throws {Error} If file not found. */
286
286
  async function readEnvFile(envPath) {
287
287
  if (!fsSync.existsSync(envPath)) {
288
288
  throw new Error(`.env file not found: ${envPath}`);
@@ -458,9 +458,10 @@ async function generateDockerCompose(appName, appConfig, options) {
458
458
  const language = appConfig.build?.language || appConfig.language || 'typescript';
459
459
  const template = loadDockerComposeTemplate(language);
460
460
  const port = options.port || appConfig.port || 3000;
461
+ const imageOverride = options.image || options.imageOverride;
461
462
  const { devId, idNum } = await getDeveloperIdAndNumeric();
462
463
  const { networkName, containerName } = buildNetworkAndContainerNames(appName, devId, idNum);
463
- const serviceConfig = buildServiceConfig(appName, appConfig, port, devId);
464
+ const serviceConfig = buildServiceConfig(appName, appConfig, port, devId, imageOverride);
464
465
  const volumesConfig = buildVolumesConfig(appName);
465
466
  const networksConfig = buildNetworksConfig(appConfig);
466
467
 
@@ -495,4 +496,3 @@ module.exports = {
495
496
  buildTraefikConfig,
496
497
  buildDevUsername
497
498
  };
498
-
@@ -8,6 +8,8 @@
8
8
  * @version 2.0.0
9
9
  */
10
10
 
11
+ const path = require('path');
12
+
11
13
  /**
12
14
  * Get path configuration value
13
15
  * @async
@@ -102,6 +104,17 @@ function createPathConfigFunctions(getConfigFn, saveConfigFn) {
102
104
  */
103
105
  async setAifabrixEnvConfigPath(envConfigPath) {
104
106
  await setPathConfig(getConfigFn, saveConfigFn, 'aifabrix-env-config', envConfigPath, 'Env config path is required and must be a string');
107
+ },
108
+
109
+ /**
110
+ * Get builder root directory (dirname of aifabrix-env-config when set).
111
+ * When set, app dirs and generated .env use this instead of cwd/builder.
112
+ * @async
113
+ * @returns {Promise<string|null>} Builder root path or null to use cwd/builder
114
+ */
115
+ async getAifabrixBuilderDir() {
116
+ const envConfigPath = await getPathConfig(getConfigFn, 'aifabrix-env-config');
117
+ return envConfigPath && typeof envConfigPath === 'string' ? path.dirname(envConfigPath) : null;
105
118
  }
106
119
  };
107
120
  }
@@ -469,12 +469,13 @@ async function refreshDeviceToken(controllerUrl, refreshToken) {
469
469
  }
470
470
 
471
471
  const url = `${controllerUrl}/api/v1/auth/login/device/refresh`;
472
+ // Send both refresh_token (OAuth2 RFC 6749 / Keycloak) and refreshToken (camelCase) so controller accepts either
472
473
  const response = await getMakeApiCall()(url, {
473
474
  method: 'POST',
474
475
  headers: {
475
476
  'Content-Type': 'application/json'
476
477
  },
477
- body: JSON.stringify({ refreshToken })
478
+ body: JSON.stringify({ refresh_token: refreshToken, refreshToken })
478
479
  });
479
480
 
480
481
  if (!response.success) {
@@ -490,7 +491,6 @@ async function refreshDeviceToken(controllerUrl, refreshToken) {
490
491
 
491
492
  return tokenResponse;
492
493
  }
493
-
494
494
  module.exports = {
495
495
  initiateDeviceCodeFlow,
496
496
  pollDeviceCodeToken,
@@ -9,7 +9,6 @@
9
9
  'use strict';
10
10
 
11
11
  const fs = require('fs');
12
- const path = require('path');
13
12
  const yaml = require('js-yaml');
14
13
  const config = require('../core/config');
15
14
  const devConfig = require('../utils/dev-config');
@@ -49,8 +48,7 @@ function splitHost(value) {
49
48
  function getLocalhostOverride(context) {
50
49
  if (context !== 'local') return null;
51
50
  try {
52
- const os = require('os');
53
- const cfgPath = path.join(os.homedir(), '.aifabrix', 'config.yaml');
51
+ const cfgPath = config.CONFIG_FILE;
54
52
  if (fs.existsSync(cfgPath)) {
55
53
  const cfgContent = fs.readFileSync(cfgPath, 'utf8');
56
54
  const cfg = yaml.load(cfgContent) || {};
@@ -310,8 +308,7 @@ async function rewriteInfraEndpoints(envContent, context, devPorts, adjustedHost
310
308
 
311
309
  // Apply config.yaml → environments.{context} override (if exists)
312
310
  try {
313
- const os = require('os');
314
- const cfgPath = path.join(os.homedir(), '.aifabrix', 'config.yaml');
311
+ const cfgPath = config.CONFIG_FILE;
315
312
  if (fs.existsSync(cfgPath)) {
316
313
  const cfgContent = fs.readFileSync(cfgPath, 'utf8');
317
314
  const cfg = yaml.load(cfgContent) || {};