@aifabrix/builder 2.33.0 → 2.33.1

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.
@@ -52,15 +52,15 @@ aifabrix validate hubspot
52
52
  aifabrix login --controller http://localhost:3100 --method device --environment dev
53
53
 
54
54
  # Register application
55
- aifabrix app register hubspot --environment dev
55
+ aifabrix app register hubspot
56
56
 
57
57
  # Deploy entire system
58
- aifabrix deploy hubspot --controller http://localhost:3100 --environment dev
58
+ aifabrix deploy hubspot
59
59
 
60
60
  # Or deploy individual datasources for testing
61
- aifabrix datasource deploy hubspot-company --environment dev --file integration/hubspot/hubspot-datasource-company.json
62
- aifabrix datasource deploy hubspot-contact --environment dev --file integration/hubspot/hubspot-datasource-contact.json
63
- aifabrix datasource deploy hubspot-deal --environment dev --file integration/hubspot/hubspot-datasource-deal.json
61
+ aifabrix datasource deploy hubspot-company --file integration/hubspot/hubspot-datasource-company.json
62
+ aifabrix datasource deploy hubspot-contact --file integration/hubspot/hubspot-datasource-contact.json
63
+ aifabrix datasource deploy hubspot-deal --file integration/hubspot/hubspot-datasource-deal.json
64
64
  ```
65
65
 
66
66
  ## Field Mappings
@@ -122,10 +122,10 @@ RBAC permissions are auto-generated: `hubspot.company.list`, `hubspot.company.ge
122
122
 
123
123
  ```bash
124
124
  # List datasources
125
- aifabrix datasource list --environment dev
125
+ aifabrix datasource list
126
126
 
127
127
  # Validate specific datasource
128
- aifabrix datasource validate hubspot-company --environment dev
128
+ aifabrix datasource validate hubspot-company
129
129
  ```
130
130
 
131
131
  ## Documentation
package/lib/api/index.js CHANGED
@@ -22,10 +22,14 @@ class ApiClient {
22
22
  * @param {string} [authConfig.clientSecret] - Client secret
23
23
  */
24
24
  constructor(baseUrl, authConfig = {}) {
25
- if (!baseUrl || typeof baseUrl !== 'string') {
25
+ if (baseUrl === null || baseUrl === undefined || typeof baseUrl !== 'string') {
26
26
  throw new Error('baseUrl is required and must be a string');
27
27
  }
28
- this.baseUrl = baseUrl.replace(/\/$/, ''); // Remove trailing slash
28
+ const trimmedUrl = baseUrl.trim().replace(/\/$/, ''); // Trim and remove trailing slash
29
+ if (!trimmedUrl) {
30
+ throw new Error('baseUrl cannot be empty. Please provide a valid URL.');
31
+ }
32
+ this.baseUrl = trimmedUrl;
29
33
  this.authConfig = authConfig;
30
34
  }
31
35
 
@@ -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 = {
package/lib/utils/api.js CHANGED
@@ -131,6 +131,32 @@ async function handleSuccessResponse(response, url, options, duration) {
131
131
  return { success: true, data: text, status: response.status };
132
132
  }
133
133
 
134
+ /**
135
+ * Validates that a URL is not empty or missing
136
+ * @function validateUrl
137
+ * @param {string} url - URL to validate
138
+ * @param {string} [urlType='URL'] - Type of URL for error message (e.g., 'Dataplane URL', 'Controller URL')
139
+ * @returns {void}
140
+ * @throws {Error} If URL is empty, null, undefined, whitespace-only, or malformed
141
+ */
142
+ function validateUrl(url, urlType = 'URL') {
143
+ if (!url || typeof url !== 'string') {
144
+ throw new Error(`${urlType} is required and must be a string (received: ${JSON.stringify(url)})`);
145
+ }
146
+ const trimmedUrl = url.trim();
147
+ if (!trimmedUrl) {
148
+ throw new Error(`${urlType} cannot be empty. Please provide a valid URL.`);
149
+ }
150
+ // Check for common invalid URL patterns
151
+ if (trimmedUrl === 'undefined' || trimmedUrl === 'null' || trimmedUrl === 'NaN') {
152
+ throw new Error(`${urlType} is invalid: "${trimmedUrl}". Please provide a valid URL.`);
153
+ }
154
+ // Basic URL format validation - must start with http:// or https://
155
+ if (!trimmedUrl.match(/^https?:\/\//i)) {
156
+ throw new Error(`${urlType} must be a valid HTTP/HTTPS URL (received: "${trimmedUrl}")`);
157
+ }
158
+ }
159
+
134
160
  /**
135
161
  * Handles network error from API call
136
162
  * @async
@@ -142,7 +168,34 @@ async function handleSuccessResponse(response, url, options, duration) {
142
168
  * @returns {Promise<Object>} Error response object
143
169
  */
144
170
  async function handleNetworkError(error, url, options, duration) {
145
- const parsedError = parseErrorResponse(error.message, 0, true);
171
+ // Enhance error message with URL information if URL is missing or invalid
172
+ let errorMessage = error.message;
173
+ if (errorMessage && (errorMessage.includes('cannot be empty') || errorMessage.includes('is required'))) {
174
+ // Add URL context to validation errors
175
+ if (!url || !url.trim()) {
176
+ errorMessage = `${errorMessage} (URL was: ${JSON.stringify(url)})`;
177
+ } else {
178
+ errorMessage = `${errorMessage} (URL was: ${url})`;
179
+ }
180
+ } else if (!url || !url.trim()) {
181
+ // If URL is empty but error doesn't mention it, add context
182
+ errorMessage = `Invalid or missing URL. ${errorMessage} (URL was: ${JSON.stringify(url)})`;
183
+ }
184
+
185
+ const parsedError = parseErrorResponse(errorMessage, 0, true);
186
+
187
+ // Extract controller URL from full URL for error data
188
+ let controllerUrl = null;
189
+ const endpointUrl = url;
190
+ if (url && typeof url === 'string' && url.trim()) {
191
+ try {
192
+ const urlObj = new URL(url);
193
+ controllerUrl = `${urlObj.protocol}//${urlObj.host}`;
194
+ } catch {
195
+ // If URL parsing fails, use the full URL as endpoint
196
+ controllerUrl = null;
197
+ }
198
+ }
146
199
 
147
200
  await logApiPerformance({
148
201
  url,
@@ -157,10 +210,17 @@ async function handleNetworkError(error, url, options, duration) {
157
210
  }
158
211
  });
159
212
 
213
+ // Include both controller URL and full endpoint URL in error data
214
+ const errorData = {
215
+ ...parsedError.data,
216
+ controllerUrl: controllerUrl,
217
+ endpointUrl: endpointUrl
218
+ };
219
+
160
220
  return {
161
221
  success: false,
162
222
  error: parsedError.message,
163
- errorData: parsedError.data,
223
+ errorData: errorData,
164
224
  errorType: parsedError.type,
165
225
  formattedError: parsedError.formatted,
166
226
  network: true
@@ -175,6 +235,14 @@ async function handleNetworkError(error, url, options, duration) {
175
235
  * @returns {Promise<Object>} Response object with success flag
176
236
  */
177
237
  async function makeApiCall(url, options = {}) {
238
+ // Validate URL before attempting request
239
+ try {
240
+ validateUrl(url, 'API endpoint URL');
241
+ } catch (error) {
242
+ const duration = 0;
243
+ return await handleNetworkError(error, url || '', options, duration);
244
+ }
245
+
178
246
  const startTime = Date.now();
179
247
  const fetchOptions = { ...options };
180
248
  if (!fetchOptions.signal) {
@@ -40,7 +40,10 @@ function addControllerUrlHeader(lines, errorData) {
40
40
  * @param {Object} errorData - Error response data
41
41
  */
42
42
  function addControllerUrlToMessage(lines, errorData) {
43
- if (errorData && errorData.controllerUrl) {
43
+ // Prefer showing full endpoint URL if available
44
+ if (errorData && errorData.endpointUrl) {
45
+ lines.push(chalk.gray(`Endpoint URL: ${errorData.endpointUrl}`));
46
+ } else if (errorData && errorData.controllerUrl) {
44
47
  lines.push(chalk.gray(`Controller URL: ${errorData.controllerUrl}`));
45
48
  }
46
49
  }
@@ -77,8 +80,15 @@ function formatHostnameNotFoundError(lines, errorData) {
77
80
  */
78
81
  function formatTimeoutError(lines, errorData) {
79
82
  lines.push(chalk.yellow('Request timed out.'));
80
- addControllerUrlToMessage(lines, errorData);
81
- lines.push(chalk.gray('The controller may be overloaded.'));
83
+
84
+ // Show full endpoint URL if available, otherwise show controller URL
85
+ if (errorData && errorData.endpointUrl) {
86
+ lines.push(chalk.gray(`Endpoint URL: ${errorData.endpointUrl}`));
87
+ } else if (errorData && errorData.controllerUrl) {
88
+ lines.push(chalk.gray(`Controller URL: ${errorData.controllerUrl}`));
89
+ }
90
+
91
+ lines.push(chalk.gray('The endpoint may not exist, the controller may be overloaded, or there may be a network issue.'));
82
92
  }
83
93
 
84
94
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aifabrix/builder",
3
- "version": "2.33.0",
3
+ "version": "2.33.1",
4
4
  "description": "AI Fabrix Local Fabric & Deployment SDK",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -12,4 +12,47 @@ jobs:
12
12
  with:
13
13
  node-version: '20'
14
14
  - name: Run tests
15
- run: npm test
15
+ run: npm test
16
+
17
+ deploy:
18
+ name: Deploy to AI Fabrix
19
+ runs-on: ubuntu-latest
20
+ needs: test
21
+ # Note: This workflow deploys to ACR (Azure Container Registry) and Azure
22
+ # For local deployment, use 'aifabrix deploy {{appName}}' directly from your machine
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+
26
+ - name: Setup Node.js
27
+ uses: actions/setup-node@v4
28
+ with:
29
+ node-version: '20'
30
+
31
+ - name: Install AI Fabrix Builder
32
+ run: npm install -g @aifabrix/builder
33
+
34
+ - name: Authenticate with Controller
35
+ run: |
36
+ set -e
37
+ aifabrix login \
38
+ --method credentials \
39
+ --app {{appName}} \
40
+ --client-id ${{ secrets.DEV_MISO_CLIENTID }} \
41
+ --client-secret ${{ secrets.DEV_MISO_CLIENTSECRET }} \
42
+ --controller ${{ secrets.MISO_CONTROLLER_URL }} \
43
+ --environment dev
44
+
45
+ - name: Validate Application Manifest
46
+ run: |
47
+ set -e
48
+ aifabrix validate {{appName}}
49
+
50
+ - name: Build Docker Image
51
+ run: |
52
+ set -e
53
+ aifabrix build {{appName}} --tag ${{ github.sha }}
54
+
55
+ - name: Deploy Application
56
+ run: |
57
+ set -e
58
+ aifabrix deploy {{appName}}
@@ -56,3 +56,47 @@ jobs:
56
56
  body: Release {{appName}} ${{ github.ref }}
57
57
  draft: false
58
58
  prerelease: false
59
+
60
+ deploy:
61
+ name: Deploy to AI Fabrix
62
+ runs-on: ubuntu-latest
63
+ needs: create-release
64
+ # Note: This workflow deploys to ACR (Azure Container Registry) and Azure
65
+ # For local deployment, use 'aifabrix deploy {{appName}}' directly from your machine
66
+ steps:
67
+ - uses: actions/checkout@v4
68
+
69
+ - name: Setup Node.js
70
+ uses: actions/setup-node@v4
71
+ with:
72
+ node-version: '20'
73
+
74
+ - name: Install AI Fabrix Builder
75
+ run: npm install -g @aifabrix/builder
76
+
77
+ - name: Authenticate with Controller
78
+ run: |
79
+ set -e
80
+ aifabrix login \
81
+ --method credentials \
82
+ --app {{appName}} \
83
+ --client-id ${{ secrets.PRO_MISO_CLIENTID }} \
84
+ --client-secret ${{ secrets.PRO_MISO_CLIENTSECRET }} \
85
+ --controller ${{ secrets.MISO_CONTROLLER_URL }} \
86
+ --environment pro
87
+
88
+ - name: Validate Application Manifest
89
+ run: |
90
+ set -e
91
+ aifabrix validate {{appName}}
92
+
93
+ - name: Build Docker Image
94
+ run: |
95
+ set -e
96
+ TAG_VERSION=${GITHUB_REF#refs/tags/v}
97
+ aifabrix build {{appName}} --tag $TAG_VERSION
98
+
99
+ - name: Deploy Application
100
+ run: |
101
+ set -e
102
+ aifabrix deploy {{appName}}