@aifabrix/builder 2.33.0 → 2.33.3

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 (50) hide show
  1. package/README.md +13 -0
  2. package/integration/hubspot/README.md +7 -7
  3. package/lib/api/index.js +6 -2
  4. package/lib/app/deploy-config.js +161 -0
  5. package/lib/app/deploy.js +28 -153
  6. package/lib/app/register.js +6 -5
  7. package/lib/app/run-helpers.js +23 -17
  8. package/lib/cli.js +31 -1
  9. package/lib/commands/logout.js +3 -4
  10. package/lib/commands/up-common.js +72 -0
  11. package/lib/commands/up-dataplane.js +109 -0
  12. package/lib/commands/up-miso.js +134 -0
  13. package/lib/core/config.js +32 -9
  14. package/lib/core/secrets-docker-env.js +88 -0
  15. package/lib/core/secrets.js +142 -115
  16. package/lib/datasource/deploy.js +31 -3
  17. package/lib/datasource/list.js +102 -15
  18. package/lib/infrastructure/helpers.js +82 -1
  19. package/lib/infrastructure/index.js +2 -0
  20. package/lib/schema/env-config.yaml +7 -0
  21. package/lib/utils/api.js +70 -2
  22. package/lib/utils/compose-generator.js +13 -13
  23. package/lib/utils/config-paths.js +13 -0
  24. package/lib/utils/device-code.js +2 -2
  25. package/lib/utils/env-endpoints.js +2 -5
  26. package/lib/utils/env-map.js +4 -5
  27. package/lib/utils/error-formatters/network-errors.js +13 -3
  28. package/lib/utils/parse-image-ref.js +27 -0
  29. package/lib/utils/paths.js +28 -4
  30. package/lib/utils/secrets-generator.js +34 -12
  31. package/lib/utils/secrets-helpers.js +1 -2
  32. package/lib/utils/token-manager-refresh.js +5 -0
  33. package/package.json +1 -1
  34. package/templates/applications/dataplane/Dockerfile +16 -0
  35. package/templates/applications/dataplane/README.md +205 -0
  36. package/templates/applications/dataplane/env.template +143 -0
  37. package/templates/applications/dataplane/rbac.yaml +283 -0
  38. package/templates/applications/dataplane/variables.yaml +143 -0
  39. package/templates/applications/keycloak/Dockerfile +1 -1
  40. package/templates/applications/keycloak/README.md +193 -0
  41. package/templates/applications/keycloak/variables.yaml +5 -6
  42. package/templates/applications/miso-controller/Dockerfile +8 -8
  43. package/templates/applications/miso-controller/README.md +369 -0
  44. package/templates/applications/miso-controller/env.template +114 -6
  45. package/templates/applications/miso-controller/rbac.yaml +74 -0
  46. package/templates/applications/miso-controller/variables.yaml +93 -5
  47. package/templates/github/ci.yaml.hbs +44 -1
  48. package/templates/github/release.yaml.hbs +44 -0
  49. package/templates/infra/compose.yaml.hbs +2 -1
  50. 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) {
@@ -118,10 +118,27 @@ async function setupDeploymentAuth(controllerUrl, environment, appKey) {
118
118
  logger.log(chalk.green('✓ Authentication successful'));
119
119
 
120
120
  logger.log(chalk.blue('🌐 Resolving dataplane URL...'));
121
- const dataplaneUrl = await resolveDataplaneUrl(controllerUrl, environment, authConfig);
122
- logger.log(chalk.green(`✓ Dataplane URL: ${dataplaneUrl}`));
121
+ let dataplaneUrl;
122
+ try {
123
+ dataplaneUrl = await resolveDataplaneUrl(controllerUrl, environment, authConfig);
124
+ logger.log(chalk.green(`✓ Dataplane URL: ${dataplaneUrl}`));
125
+ } catch (error) {
126
+ logger.error(chalk.red('❌ Failed to resolve dataplane URL:'), error.message);
127
+ logger.error(chalk.gray('\nThe dataplane URL is automatically discovered from the controller.'));
128
+ logger.error(chalk.gray('If discovery fails, ensure you are logged in and the controller is accessible:'));
129
+ logger.error(chalk.gray(' aifabrix login'));
130
+ throw error;
131
+ }
132
+
133
+ // Validate dataplane URL
134
+ if (!dataplaneUrl || !dataplaneUrl.trim()) {
135
+ logger.error(chalk.red('❌ Dataplane URL is empty.'));
136
+ logger.error(chalk.gray('The dataplane URL could not be discovered from the controller.'));
137
+ logger.error(chalk.gray('Ensure the dataplane service is registered in the controller.'));
138
+ throw new Error('Dataplane URL is empty');
139
+ }
123
140
 
124
- return { authConfig, dataplaneUrl };
141
+ return { authConfig, dataplaneUrl: dataplaneUrl.trim() };
125
142
  }
126
143
 
127
144
  /**
@@ -143,6 +160,17 @@ async function publishDatasourceToDataplane(dataplaneUrl, systemKey, authConfig,
143
160
  const formattedError = publishResponse.formattedError || formatApiError(publishResponse);
144
161
  logger.error(chalk.red('❌ Publish failed:'));
145
162
  logger.error(formattedError);
163
+
164
+ // Show dataplane URL and endpoint information
165
+ if (publishResponse.errorData && publishResponse.errorData.endpointUrl) {
166
+ logger.error(chalk.gray(`\nEndpoint URL: ${publishResponse.errorData.endpointUrl}`));
167
+ } else if (dataplaneUrl) {
168
+ logger.error(chalk.gray(`\nDataplane URL: ${dataplaneUrl}`));
169
+ logger.error(chalk.gray(`System Key: ${systemKey}`));
170
+ }
171
+
172
+ logger.error(chalk.gray('\nFull response for debugging:'));
173
+ logger.error(chalk.gray(JSON.stringify(publishResponse, null, 2)));
146
174
  throw new Error(`Dataplane publish failed: ${formattedError}`);
147
175
  }
148
176
 
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Datasource List Command
3
3
  *
4
- * Lists datasources from an environment via controller API.
4
+ * Lists datasources from an environment via dataplane API.
5
+ * Gets dataplane URL from controller, then lists datasources from dataplane.
5
6
  *
6
7
  * @fileoverview Datasource listing for AI Fabrix Builder
7
8
  * @author AI Fabrix Team
@@ -11,7 +12,8 @@
11
12
  const chalk = require('chalk');
12
13
  const { getConfig, resolveEnvironment } = require('../core/config');
13
14
  const { getOrRefreshDeviceToken } = require('../utils/token-manager');
14
- const { listEnvironmentDatasources } = require('../api/environments.api');
15
+ const { listDatasources: listDatasourcesFromDataplane } = require('../api/datasources-core.api');
16
+ const { resolveDataplaneUrl } = require('../utils/dataplane-resolver');
15
17
  const { formatApiError } = require('../utils/api-error-handler');
16
18
  const logger = require('../utils/logger');
17
19
 
@@ -103,14 +105,19 @@ function extractDatasources(response) {
103
105
  * @function displayDatasources
104
106
  * @param {Array} datasources - Array of datasource objects
105
107
  * @param {string} environment - Environment key
108
+ * @param {string} dataplaneUrl - Dataplane URL for header display
106
109
  */
107
- function displayDatasources(datasources, environment) {
110
+ function displayDatasources(datasources, environment, dataplaneUrl) {
111
+ const environmentName = environment || 'dev';
112
+ const header = `Datasources in ${environmentName} environment${dataplaneUrl ? ` (${dataplaneUrl})` : ''}`;
113
+
108
114
  if (datasources.length === 0) {
109
- logger.log(chalk.yellow(`\nNo datasources found in environment: ${environment}`));
115
+ logger.log(chalk.bold(`\n📋 ${header}:\n`));
116
+ logger.log(chalk.gray(' No datasources found in this environment.\n'));
110
117
  return;
111
118
  }
112
119
 
113
- logger.log(chalk.blue(`\n📋 Datasources in environment: ${environment}\n`));
120
+ logger.log(chalk.bold(`\n📋 ${header}:\n`));
114
121
  logger.log(chalk.gray('Key'.padEnd(30) + 'Display Name'.padEnd(30) + 'System Key'.padEnd(20) + 'Version'.padEnd(15) + 'Status'));
115
122
  logger.log(chalk.gray('-'.repeat(120)));
116
123
 
@@ -169,7 +176,7 @@ async function getDeviceTokenFromConfig(config) {
169
176
  * @param {string|null} controllerUrl - Controller URL
170
177
  */
171
178
  function validateDatasourceListingAuth(token, controllerUrl) {
172
- if (!token || !controllerUrl) {
179
+ if (!token || !controllerUrl || (typeof controllerUrl === 'string' && !controllerUrl.trim())) {
173
180
  logger.error(chalk.red('❌ Not logged in. Run: aifabrix login'));
174
181
  logger.error(chalk.gray(' Use device code flow: aifabrix login --method device --controller <url>'));
175
182
  process.exit(1);
@@ -181,14 +188,92 @@ function validateDatasourceListingAuth(token, controllerUrl) {
181
188
  * @function handleDatasourceApiError
182
189
  * @param {Object} response - API response
183
190
  */
184
- function handleDatasourceApiError(response) {
191
+ function handleDatasourceApiError(response, dataplaneUrl = null) {
185
192
  const formattedError = response.formattedError || formatApiError(response);
186
193
  logger.error(formattedError);
194
+
195
+ // Show endpoint URL from error data if available (more specific than dataplane URL)
196
+ if (response.errorData && response.errorData.endpointUrl) {
197
+ logger.error(chalk.gray(`\nEndpoint URL: ${response.errorData.endpointUrl}`));
198
+ } else if (response.errorData && response.errorData.controllerUrl) {
199
+ logger.error(chalk.gray(`\nDataplane URL: ${response.errorData.controllerUrl}`));
200
+ } else if (dataplaneUrl) {
201
+ logger.error(chalk.gray(`\nDataplane URL: ${dataplaneUrl}`));
202
+ }
203
+
187
204
  logger.error(chalk.gray('\nFull response for debugging:'));
188
205
  logger.error(chalk.gray(JSON.stringify(response, null, 2)));
189
206
  process.exit(1);
190
207
  }
191
208
 
209
+ /**
210
+ * Validates and trims controller URL
211
+ * @function validateControllerUrl
212
+ * @param {string} controllerUrl - Controller URL to validate
213
+ * @returns {string} Trimmed controller URL
214
+ */
215
+ function validateControllerUrl(controllerUrl) {
216
+ const trimmed = controllerUrl.trim();
217
+ if (!trimmed) {
218
+ logger.error(chalk.red('❌ Controller URL is empty.'));
219
+ logger.error(chalk.gray(` Controller URL from config: ${JSON.stringify(controllerUrl)}`));
220
+ logger.error(chalk.gray(' Run: aifabrix login --method device --controller <url>'));
221
+ process.exit(1);
222
+ }
223
+ return trimmed;
224
+ }
225
+
226
+ /**
227
+ * Resolves and validates dataplane URL from controller
228
+ * @async
229
+ * @function resolveAndValidateDataplaneUrl
230
+ * @param {string} controllerUrl - Controller URL
231
+ * @param {string} environment - Environment key
232
+ * @param {Object} authConfig - Authentication configuration
233
+ * @returns {Promise<string>} Validated dataplane URL
234
+ */
235
+ async function resolveAndValidateDataplaneUrl(controllerUrl, environment, authConfig) {
236
+ let dataplaneUrl;
237
+ try {
238
+ // discoverDataplaneUrl already logs progress and success messages
239
+ dataplaneUrl = await resolveDataplaneUrl(controllerUrl, environment, authConfig);
240
+ } catch (error) {
241
+ logger.error(chalk.red('❌ Failed to resolve dataplane URL:'), error.message);
242
+ logger.error(chalk.gray('\nThe dataplane URL is automatically discovered from the controller.'));
243
+ logger.error(chalk.gray('If discovery fails, ensure you are logged in and the controller is accessible:'));
244
+ logger.error(chalk.gray(' aifabrix login'));
245
+ process.exit(1);
246
+ // eslint-disable-next-line no-unreachable
247
+ throw error; // Never reached in production, but needed for tests when process.exit is mocked
248
+ }
249
+
250
+ if (!dataplaneUrl || typeof dataplaneUrl !== 'string' || !dataplaneUrl.trim()) {
251
+ logger.error(chalk.red('❌ Dataplane URL is empty.'));
252
+ logger.error(chalk.gray('The dataplane URL could not be discovered from the controller.'));
253
+ logger.error(chalk.gray('Ensure the dataplane service is registered in the controller.'));
254
+ process.exit(1);
255
+ // eslint-disable-next-line no-unreachable
256
+ throw new Error('Dataplane URL is empty'); // Never reached in production, but needed for tests
257
+ }
258
+
259
+ return dataplaneUrl.trim();
260
+ }
261
+
262
+ /**
263
+ * Sets up authentication configuration for dataplane API calls
264
+ * @function setupAuthConfig
265
+ * @param {string} token - Authentication token
266
+ * @param {string} controllerUrl - Controller URL
267
+ * @returns {Object} Authentication configuration
268
+ */
269
+ function setupAuthConfig(token, controllerUrl) {
270
+ return {
271
+ type: 'bearer',
272
+ token: token,
273
+ controller: controllerUrl
274
+ };
275
+ }
276
+
192
277
  async function listDatasources(_options) {
193
278
  const config = await getConfig();
194
279
 
@@ -199,22 +284,24 @@ async function listDatasources(_options) {
199
284
  const authInfo = await getDeviceTokenFromConfig(config);
200
285
  validateDatasourceListingAuth(authInfo?.token, authInfo?.controllerUrl);
201
286
 
202
- // Call controller API using centralized API client
203
- // Note: validateDatasourceListingAuth will exit if auth is missing, so this check is defensive
204
287
  if (!authInfo || !authInfo.token || !authInfo.controllerUrl) {
205
- validateDatasourceListingAuth(null, null); // This will exit
206
- return; // Never reached, but satisfies linter
288
+ validateDatasourceListingAuth(null, null);
289
+ return;
207
290
  }
208
- const authConfig = { type: 'bearer', token: authInfo.token };
209
- const response = await listEnvironmentDatasources(authInfo.controllerUrl, environment, authConfig);
291
+
292
+ const controllerUrl = validateControllerUrl(authInfo.controllerUrl);
293
+ const authConfig = setupAuthConfig(authInfo.token, controllerUrl);
294
+ const dataplaneUrl = await resolveAndValidateDataplaneUrl(controllerUrl, environment, authConfig);
295
+
296
+ const response = await listDatasourcesFromDataplane(dataplaneUrl, authConfig);
210
297
 
211
298
  if (!response.success || !response.data) {
212
- handleDatasourceApiError(response);
299
+ handleDatasourceApiError(response, dataplaneUrl);
213
300
  return; // Ensure we don't continue after exit
214
301
  }
215
302
 
216
303
  const datasources = extractDatasources(response);
217
- displayDatasources(datasources, environment);
304
+ displayDatasources(datasources, environment, dataplaneUrl);
218
305
  }
219
306
 
220
307
  module.exports = {
@@ -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
  }