@aifabrix/builder 2.37.0 → 2.37.9

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.
@@ -12,6 +12,19 @@ const chalk = require('chalk');
12
12
  const logger = require('../utils/logger');
13
13
  const { generateExternalReadmeContent } = require('../utils/external-readme');
14
14
 
15
+ /**
16
+ * Converts a string to a schema-valid key segment (lowercase letters, numbers, hyphens only).
17
+ * e.g. "recordStorage" -> "record-storage", "documentStorage" -> "document-storage"
18
+ * @param {string} str - Raw entity type or key segment (may be camelCase)
19
+ * @returns {string} Segment matching ^[a-z0-9-]+$
20
+ */
21
+ function toKeySegment(str) {
22
+ if (!str || typeof str !== 'string') return 'default';
23
+ const withHyphens = str.replace(/([A-Z])/g, '-$1').toLowerCase();
24
+ const sanitized = withHyphens.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
25
+ return sanitized || 'default';
26
+ }
27
+
15
28
  /**
16
29
  * Generate files from dataplane-generated wizard configurations
17
30
  * @async
@@ -53,11 +66,12 @@ async function writeDatasourceJsonFiles(appPath, finalSystemKey, datasourceConfi
53
66
  const datasourceFileNames = [];
54
67
  for (const datasourceConfig of datasourceConfigs) {
55
68
  const entityType = datasourceConfig.entityType || datasourceConfig.entityKey || datasourceConfig.key?.split('-').pop() || 'default';
56
- const datasourceKey = datasourceConfig.key || `${finalSystemKey}-${entityType}`;
57
- // Extract datasource key (remove system key prefix if present)
69
+ const keySegment = toKeySegment(entityType);
70
+ const datasourceKey = datasourceConfig.key || `${finalSystemKey}-${keySegment}`;
71
+ // Extract datasource key (remove system key prefix if present); use normalized segment for filename
58
72
  const datasourceKeyOnly = datasourceKey.includes('-') && datasourceKey.startsWith(`${finalSystemKey}-`)
59
73
  ? datasourceKey.substring(finalSystemKey.length + 1)
60
- : entityType;
74
+ : keySegment;
61
75
  const datasourceFileName = `${finalSystemKey}-datasource-${datasourceKeyOnly}.json`;
62
76
  const datasourceFilePath = path.join(appPath, datasourceFileName);
63
77
  await fs.writeFile(datasourceFilePath, JSON.stringify(datasourceConfig, null, 2), 'utf8');
@@ -143,13 +157,14 @@ async function generateWizardFiles(appName, systemConfig, datasourceConfigs, sys
143
157
  displayName: appDisplayName
144
158
  };
145
159
 
146
- // Update datasource configs to use appName-based keys and systemKey
160
+ // Update datasource configs to use appName-based keys and systemKey (key must match ^[a-z0-9-]+$)
147
161
  const updatedDatasourceConfigs = datasourceConfigs.map(ds => {
148
162
  const entityType = ds.entityType || ds.entityKey || ds.key?.split('-').pop() || 'default';
163
+ const keySegment = toKeySegment(entityType);
149
164
  const entityDisplayName = entityType.charAt(0).toUpperCase() + entityType.slice(1).replace(/-/g, ' ');
150
165
  return {
151
166
  ...ds,
152
- key: `${finalSystemKey}-${entityType}`,
167
+ key: `${finalSystemKey}-${keySegment}`,
153
168
  systemKey: finalSystemKey,
154
169
  displayName: `${appDisplayName} ${entityDisplayName}`
155
170
  };
@@ -360,23 +375,22 @@ async function generateEnvTemplate(appPath, systemConfig) {
360
375
  }
361
376
 
362
377
  /**
363
- * Generate deployment scripts (deploy.sh, deploy.ps1, deploy.js) from templates
378
+ * Generate deployment script (deploy.js) from template
364
379
  * @async
365
380
  * @function generateDeployScripts
366
381
  * @param {string} appPath - Application directory path
367
382
  * @param {string} systemKey - System key
368
383
  * @param {string} systemFileName - System file name
369
384
  * @param {string[]} datasourceFileNames - Array of datasource file names
370
- * @returns {Promise<Object>} Object with deployShPath, deployPs1Path, deployJsPath
385
+ * @returns {Promise<Object>} Object with deployJsPath
371
386
  * @throws {Error} If generation fails
372
387
  */
373
388
  const templatesExternalDir = path.join(__dirname, '..', '..', 'templates', 'external-system');
374
389
 
375
- async function writeDeployScriptFromTemplate(templateName, outputPath, context, executable) {
390
+ async function writeDeployScriptFromTemplate(templateName, outputPath, context) {
376
391
  const templatePath = path.join(templatesExternalDir, templateName);
377
392
  const content = Handlebars.compile(await fs.readFile(templatePath, 'utf8'))(context);
378
393
  await fs.writeFile(outputPath, content, 'utf8');
379
- if (executable) await fs.chmod(outputPath, 0o755);
380
394
  logger.log(chalk.green(`✓ Generated ${path.basename(outputPath)}`));
381
395
  }
382
396
 
@@ -385,13 +399,9 @@ async function generateDeployScripts(appPath, systemKey, systemFileName, datasou
385
399
  const allJsonFiles = [systemFileName, ...datasourceFileNames];
386
400
  const context = { systemKey, allJsonFiles, datasourceFileNames };
387
401
 
388
- await writeDeployScriptFromTemplate('deploy.sh.hbs', path.join(appPath, 'deploy.sh'), context, true);
389
- await writeDeployScriptFromTemplate('deploy.ps1.hbs', path.join(appPath, 'deploy.ps1'), context, false);
390
- await writeDeployScriptFromTemplate('deploy.js.hbs', path.join(appPath, 'deploy.js'), context, false);
402
+ await writeDeployScriptFromTemplate('deploy.js.hbs', path.join(appPath, 'deploy.js'), context);
391
403
 
392
404
  return {
393
- deployShPath: path.join(appPath, 'deploy.sh'),
394
- deployPs1Path: path.join(appPath, 'deploy.ps1'),
395
405
  deployJsPath: path.join(appPath, 'deploy.js')
396
406
  };
397
407
  } catch (error) {
@@ -424,10 +434,11 @@ async function generateReadme(appPath, appName, systemKey, systemConfig, datasou
424
434
 
425
435
  const datasources = (Array.isArray(datasourceConfigs) ? datasourceConfigs : []).map((ds, index) => {
426
436
  const entityType = ds.entityType || ds.entityKey || ds.key?.split('-').pop() || `datasource${index + 1}`;
427
- const datasourceKey = ds.key || `${systemKey}-${entityType}`;
437
+ const keySegment = toKeySegment(entityType);
438
+ const datasourceKey = ds.key || `${systemKey}-${keySegment}`;
428
439
  const datasourceKeyOnly = datasourceKey.includes('-') && datasourceKey.startsWith(`${systemKey}-`)
429
440
  ? datasourceKey.substring(systemKey.length + 1)
430
- : entityType;
441
+ : keySegment;
431
442
  return {
432
443
  entityType,
433
444
  displayName: ds.displayName || ds.name || ds.key || `Datasource ${index + 1}`,
@@ -0,0 +1,64 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://raw.githubusercontent.com/esystemsdev/aifabrix-builder/refs/heads/main/lib/schema/environment-deploy-request.schema.json",
4
+ "title": "Environment Deploy Request",
5
+ "description": "Request body for POST /api/v1/environments/{envKey}/deploy",
6
+ "type": "object",
7
+ "required": ["environmentConfig"],
8
+ "properties": {
9
+ "environmentConfig": {
10
+ "type": "object",
11
+ "description": "Environment infrastructure configuration",
12
+ "required": ["key", "environment", "preset", "serviceName", "location"],
13
+ "properties": {
14
+ "key": {
15
+ "type": "string",
16
+ "description": "Environment key",
17
+ "pattern": "^[a-z0-9-]+$",
18
+ "minLength": 2,
19
+ "maxLength": 40
20
+ },
21
+ "environment": {
22
+ "type": "string",
23
+ "description": "Environment type",
24
+ "enum": ["miso", "dev", "tst", "pro"]
25
+ },
26
+ "preset": {
27
+ "type": "string",
28
+ "description": "Deployment preset size",
29
+ "enum": ["evaluation", "eval", "s", "m", "l", "xl"]
30
+ },
31
+ "serviceName": {
32
+ "type": "string",
33
+ "description": "Service name for resource naming",
34
+ "pattern": "^[a-z0-9-]{2,40}$"
35
+ },
36
+ "location": {
37
+ "type": "string",
38
+ "description": "Azure region (e.g. swedencentral)"
39
+ },
40
+ "resourceGroupName": { "type": "string" },
41
+ "subscriptionId": { "type": "string" },
42
+ "tenantId": { "type": "string" },
43
+ "customDomain": { "type": "object" },
44
+ "frontDoor": { "type": "object" },
45
+ "networking": { "type": "object" },
46
+ "allowedIPs": {
47
+ "type": "array",
48
+ "items": { "type": "string" }
49
+ },
50
+ "infrastructureAccess": {
51
+ "type": "array",
52
+ "items": { "type": "object" }
53
+ }
54
+ },
55
+ "additionalProperties": true
56
+ },
57
+ "dryRun": {
58
+ "type": "boolean",
59
+ "description": "If true, validate only without deploying",
60
+ "default": false
61
+ }
62
+ },
63
+ "additionalProperties": false
64
+ }
@@ -31,11 +31,15 @@ function safeHomedir() {
31
31
  }
32
32
 
33
33
  /**
34
- * Returns the path to the config file (AIFABRIX_HOME env or ~/.aifabrix).
35
- * Used so getAifabrixHome can read from the same location as config.js.
34
+ * Returns the path to the config directory (same precedence as config.js so both read the same config).
35
+ * Priority: AIFABRIX_CONFIG (dirname) AIFABRIX_HOME ~/.aifabrix.
36
36
  * @returns {string} Absolute path to config directory
37
37
  */
38
38
  function getConfigDirForPaths() {
39
+ const configFile = process.env.AIFABRIX_CONFIG && typeof process.env.AIFABRIX_CONFIG === 'string';
40
+ if (configFile) {
41
+ return path.dirname(path.resolve(process.env.AIFABRIX_CONFIG.trim()));
42
+ }
39
43
  if (process.env.AIFABRIX_HOME && typeof process.env.AIFABRIX_HOME === 'string') {
40
44
  return path.resolve(process.env.AIFABRIX_HOME.trim());
41
45
  }
@@ -281,7 +285,23 @@ function getAppPath(appName, appType) {
281
285
  }
282
286
 
283
287
  /**
284
- * Gets the integration folder path for external systems
288
+ * Base directory for integration/builder: project root when cwd is inside project, else cwd.
289
+ * So deploy works when run from integration/<app> (e.g. node deploy.js), and tests using temp dirs still work.
290
+ * @returns {string} Directory to resolve integration/ and builder/ from
291
+ */
292
+ function getIntegrationBuilderBaseDir() {
293
+ const root = getProjectRoot();
294
+ const cwd = path.resolve(process.cwd());
295
+ const rootNorm = path.resolve(root);
296
+ if (cwd === rootNorm || cwd.startsWith(rootNorm + path.sep)) {
297
+ return rootNorm;
298
+ }
299
+ return cwd;
300
+ }
301
+
302
+ /**
303
+ * Gets the integration folder path for external systems.
304
+ * Uses project root when cwd is inside project so deploy works when run from integration/<app> (e.g. node deploy.js).
285
305
  * @param {string} appName - Application name
286
306
  * @returns {string} Absolute path to integration directory
287
307
  */
@@ -289,13 +309,14 @@ function getIntegrationPath(appName) {
289
309
  if (!appName || typeof appName !== 'string') {
290
310
  throw new Error('App name is required and must be a string');
291
311
  }
292
- return path.join(process.cwd(), 'integration', appName);
312
+ const base = getIntegrationBuilderBaseDir();
313
+ return path.join(base, 'integration', appName);
293
314
  }
294
315
 
295
316
  /**
296
317
  * Gets the builder folder path for regular applications.
297
318
  * When AIFABRIX_BUILDER_DIR is set (e.g. by up-miso/up-dataplane from config aifabrix-env-config),
298
- * uses that as builder root instead of cwd/builder.
319
+ * uses that as builder root; otherwise uses project root so deploy works when run from integration/<app>.
299
320
  * @param {string} appName - Application name
300
321
  * @returns {string} Absolute path to builder directory
301
322
  */
@@ -309,7 +330,8 @@ function getBuilderPath(appName) {
309
330
  if (builderRoot) {
310
331
  return path.join(builderRoot, appName);
311
332
  }
312
- return path.join(process.cwd(), 'builder', appName);
333
+ const base = getIntegrationBuilderBaseDir();
334
+ return path.join(base, 'builder', appName);
313
335
  }
314
336
 
315
337
  /**
@@ -462,7 +484,6 @@ async function detectAppType(appName, options = {}) {
462
484
  // Check builder folder (backward compatibility)
463
485
  return checkBuilderFolder(appName);
464
486
  }
465
-
466
487
  module.exports = {
467
488
  getAifabrixHome,
468
489
  getConfigDirForPaths,
@@ -18,7 +18,17 @@ const logger = require('./logger');
18
18
  const pathsUtil = require('./paths');
19
19
 
20
20
  /**
21
- * Finds missing secret keys from template
21
+ * Skips commented or empty lines when scanning env.template
22
+ * @param {string} line - Single line
23
+ * @returns {boolean}
24
+ */
25
+ function isCommentOrEmptyLine(line) {
26
+ const t = line.trim();
27
+ return t === '' || t.startsWith('#');
28
+ }
29
+
30
+ /**
31
+ * Finds missing secret keys from template (skips commented and empty lines)
22
32
  * @function findMissingSecretKeys
23
33
  * @param {string} envTemplate - Environment template content
24
34
  * @param {Object} existingSecrets - Existing secrets object
@@ -28,13 +38,18 @@ function findMissingSecretKeys(envTemplate, existingSecrets) {
28
38
  const kvPattern = /kv:\/\/([a-zA-Z0-9-_]+)/g;
29
39
  const missingKeys = [];
30
40
  const seenKeys = new Set();
31
-
32
- let match;
33
- while ((match = kvPattern.exec(envTemplate)) !== null) {
34
- const secretKey = match[1];
35
- if (!seenKeys.has(secretKey) && !(secretKey in existingSecrets)) {
36
- missingKeys.push(secretKey);
37
- seenKeys.add(secretKey);
41
+ const lines = envTemplate.split('\n');
42
+
43
+ for (const line of lines) {
44
+ if (isCommentOrEmptyLine(line)) continue;
45
+ let match;
46
+ kvPattern.lastIndex = 0;
47
+ while ((match = kvPattern.exec(line)) !== null) {
48
+ const secretKey = match[1];
49
+ if (!seenKeys.has(secretKey) && !(secretKey in existingSecrets)) {
50
+ missingKeys.push(secretKey);
51
+ seenKeys.add(secretKey);
52
+ }
38
53
  }
39
54
  }
40
55
 
@@ -33,7 +33,17 @@ function interpolateEnvVars(content, envVars) {
33
33
  }
34
34
 
35
35
  /**
36
- * Collect missing kv:// secrets referenced in content
36
+ * Returns true if the line is a comment or empty (should be skipped for kv:// resolution)
37
+ * @param {string} line - Single line
38
+ * @returns {boolean}
39
+ */
40
+ function isCommentOrEmptyLine(line) {
41
+ const t = line.trim();
42
+ return t === '' || t.startsWith('#');
43
+ }
44
+
45
+ /**
46
+ * Collect missing kv:// secrets referenced in content (skips commented and empty lines)
37
47
  * @function collectMissingSecrets
38
48
  * @param {string} content - Text content
39
49
  * @param {Object} secrets - Available secrets
@@ -42,11 +52,16 @@ function interpolateEnvVars(content, envVars) {
42
52
  function collectMissingSecrets(content, secrets) {
43
53
  const kvPattern = /kv:\/\/([a-zA-Z0-9-_]+)/g;
44
54
  const missing = [];
45
- let match;
46
- while ((match = kvPattern.exec(content)) !== null) {
47
- const secretKey = match[1];
48
- if (!(secretKey in secrets)) {
49
- missing.push(`kv://${secretKey}`);
55
+ const lines = content.split('\n');
56
+ for (const line of lines) {
57
+ if (isCommentOrEmptyLine(line)) continue;
58
+ let match;
59
+ kvPattern.lastIndex = 0;
60
+ while ((match = kvPattern.exec(line)) !== null) {
61
+ const secretKey = match[1];
62
+ if (!(secretKey in secrets)) {
63
+ missing.push(`kv://${secretKey}`);
64
+ }
50
65
  }
51
66
  }
52
67
  return missing;
@@ -76,7 +91,7 @@ function formatMissingSecretsFileInfo(secretsFilePaths) {
76
91
  }
77
92
 
78
93
  /**
79
- * Replace kv:// references with actual values, after also interpolating any ${VAR} within secret values
94
+ * Replace kv:// references with actual values (skips commented and empty lines)
80
95
  * @function replaceKvInContent
81
96
  * @param {string} content - Text content containing kv:// references
82
97
  * @param {Object} secrets - Secrets map
@@ -85,15 +100,20 @@ function formatMissingSecretsFileInfo(secretsFilePaths) {
85
100
  */
86
101
  function replaceKvInContent(content, secrets, envVars) {
87
102
  const kvPattern = /kv:\/\/([a-zA-Z0-9-_]+)/g;
88
- return content.replace(kvPattern, (match, secretKey) => {
89
- let value = secrets[secretKey];
90
- if (typeof value === 'string') {
91
- value = value.replace(/\$\{([A-Z_]+)\}/g, (m, envVar) => {
92
- return envVars[envVar] || m;
93
- });
94
- }
95
- return value;
103
+ const lines = content.split('\n');
104
+ const result = lines.map(line => {
105
+ if (isCommentOrEmptyLine(line)) return line;
106
+ return line.replace(kvPattern, (match, secretKey) => {
107
+ let value = secrets[secretKey];
108
+ if (typeof value === 'string') {
109
+ value = value.replace(/\$\{([A-Z_]+)\}/g, (m, envVar) => {
110
+ return envVars[envVar] || m;
111
+ });
112
+ }
113
+ return value;
114
+ });
96
115
  });
116
+ return result.join('\n');
97
117
  }
98
118
 
99
119
  /**
@@ -420,7 +440,7 @@ function ensureNonEmptySecrets(secrets) {
420
440
  }
421
441
 
422
442
  /**
423
- * Validate secrets against the env template, returning missing refs
443
+ * Validate secrets against the env template (skips commented and empty lines)
424
444
  * @function validateSecrets
425
445
  * @param {string} envTemplate - Environment template content
426
446
  * @param {Object} secrets - Available secrets
@@ -429,11 +449,16 @@ function ensureNonEmptySecrets(secrets) {
429
449
  function validateSecrets(envTemplate, secrets) {
430
450
  const kvPattern = /kv:\/\/([a-zA-Z0-9-_]+)/g;
431
451
  const missing = [];
432
- let match;
433
- while ((match = kvPattern.exec(envTemplate)) !== null) {
434
- const secretKey = match[1];
435
- if (!(secretKey in secrets)) {
436
- missing.push(`kv://${secretKey}`);
452
+ const lines = envTemplate.split('\n');
453
+ for (const line of lines) {
454
+ if (isCommentOrEmptyLine(line)) continue;
455
+ let match;
456
+ kvPattern.lastIndex = 0;
457
+ while ((match = kvPattern.exec(line)) !== null) {
458
+ const secretKey = match[1];
459
+ if (!(secretKey in secrets)) {
460
+ missing.push(`kv://${secretKey}`);
461
+ }
437
462
  }
438
463
  }
439
464
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aifabrix/builder",
3
- "version": "2.37.0",
3
+ "version": "2.37.9",
4
4
  "description": "AI Fabrix Local Fabric & Deployment SDK",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -114,10 +114,19 @@ function installLocal() {
114
114
  console.log('Linking @aifabrix/builder globally...\n');
115
115
 
116
116
  try {
117
+ const projectRoot = path.join(__dirname, '..');
117
118
  if (pm === 'pnpm') {
118
- execSync('pnpm link --global', { stdio: 'inherit' });
119
+ // Update pnpm global.
120
+ execSync('pnpm link --global', { stdio: 'inherit', cwd: projectRoot });
121
+ // Also run npm link so npm's global bin points here; often PATH has
122
+ // npm's global bin before pnpm's, so "aifabrix" would otherwise stay old.
123
+ try {
124
+ execSync('npm link', { stdio: 'inherit', cwd: projectRoot });
125
+ } catch {
126
+ // npm may not be available or may fail; pnpm link already ran
127
+ }
119
128
  } else {
120
- execSync('npm link', { stdio: 'inherit' });
129
+ execSync('npm link', { stdio: 'inherit', cwd: projectRoot });
121
130
  }
122
131
 
123
132
  // Get new version after linking
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ /* eslint-disable no-console */
2
3
  /**
3
4
  * Deploy {{systemKey}} external system and datasources using aifabrix CLI.
4
5
  * Run: node deploy.js
@@ -13,6 +14,12 @@ const appKey = '{{systemKey}}';
13
14
  const env = process.env.ENVIRONMENT || 'dev';
14
15
  // Controller URL: from config (aifabrix auth config) or set CONTROLLER env before running
15
16
 
17
+ /**
18
+ * Run a shell command with optional options.
19
+ * @param {string} cmd - Command to run
20
+ * @param {Object} [options={}] - Options (cwd, stdio, ignoreExit)
21
+ * @returns {boolean} True if command succeeded
22
+ */
16
23
  function run(cmd, options = {}) {
17
24
  const opts = { cwd: scriptDir, stdio: 'inherit', ...options };
18
25
  try {
@@ -24,6 +31,10 @@ function run(cmd, options = {}) {
24
31
  }
25
32
  }
26
33
 
34
+ /**
35
+ * Check if aifabrix auth is logged in.
36
+ * @returns {boolean} True if logged in
37
+ */
27
38
  function isLoggedIn() {
28
39
  try {
29
40
  execSync('aifabrix auth status', { cwd: scriptDir, stdio: 'pipe' });
@@ -0,0 +1,10 @@
1
+ {
2
+ "environmentConfig": {
3
+ "key": "dev",
4
+ "environment": "dev",
5
+ "preset": "s",
6
+ "serviceName": "aifabrix",
7
+ "location": "swedencentral"
8
+ },
9
+ "dryRun": false
10
+ }