@aifabrix/builder 2.1.6 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/lib/app-deploy.js +73 -29
  2. package/lib/app-list.js +132 -0
  3. package/lib/app-readme.js +11 -4
  4. package/lib/app-register.js +435 -0
  5. package/lib/app-rotate-secret.js +164 -0
  6. package/lib/app-run.js +98 -84
  7. package/lib/app.js +13 -0
  8. package/lib/audit-logger.js +195 -15
  9. package/lib/build.js +57 -37
  10. package/lib/cli.js +90 -8
  11. package/lib/commands/app.js +8 -391
  12. package/lib/commands/login.js +130 -36
  13. package/lib/config.js +257 -4
  14. package/lib/deployer.js +221 -183
  15. package/lib/infra.js +177 -112
  16. package/lib/secrets.js +85 -99
  17. package/lib/utils/api-error-handler.js +465 -0
  18. package/lib/utils/api.js +165 -16
  19. package/lib/utils/auth-headers.js +84 -0
  20. package/lib/utils/build-copy.js +144 -0
  21. package/lib/utils/cli-utils.js +21 -0
  22. package/lib/utils/compose-generator.js +43 -14
  23. package/lib/utils/deployment-errors.js +90 -0
  24. package/lib/utils/deployment-validation.js +60 -0
  25. package/lib/utils/dev-config.js +83 -0
  26. package/lib/utils/env-template.js +30 -10
  27. package/lib/utils/health-check.js +18 -1
  28. package/lib/utils/infra-containers.js +101 -0
  29. package/lib/utils/local-secrets.js +0 -2
  30. package/lib/utils/secrets-path.js +18 -21
  31. package/lib/utils/secrets-utils.js +206 -0
  32. package/lib/utils/token-manager.js +381 -0
  33. package/package.json +1 -1
  34. package/templates/applications/README.md.hbs +155 -23
  35. package/templates/applications/miso-controller/Dockerfile +7 -119
  36. package/templates/infra/compose.yaml.hbs +93 -0
  37. package/templates/python/docker-compose.hbs +25 -17
  38. package/templates/typescript/docker-compose.hbs +25 -17
@@ -0,0 +1,206 @@
1
+ /**
2
+ * AI Fabrix Builder Secrets Utilities
3
+ *
4
+ * This module provides utility functions for loading and processing secrets.
5
+ * Helper functions for secrets.js module.
6
+ *
7
+ * @fileoverview Secrets utility functions for AI Fabrix Builder
8
+ * @author AI Fabrix Team
9
+ * @version 2.0.0
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const yaml = require('js-yaml');
15
+ const os = require('os');
16
+ const logger = require('./logger');
17
+
18
+ /**
19
+ * Loads secrets from file with cascading lookup support
20
+ * First checks ~/.aifabrix/secrets.local.yaml, then build.secrets from variables.yaml
21
+ *
22
+ * @async
23
+ * @function loadSecretsFromFile
24
+ * @param {string} filePath - Path to secrets file
25
+ * @returns {Promise<Object>} Loaded secrets object or empty object if file doesn't exist
26
+ */
27
+ async function loadSecretsFromFile(filePath) {
28
+ if (!fs.existsSync(filePath)) {
29
+ return {};
30
+ }
31
+
32
+ try {
33
+ const content = fs.readFileSync(filePath, 'utf8');
34
+ const secrets = yaml.load(content);
35
+
36
+ if (!secrets || typeof secrets !== 'object') {
37
+ return {};
38
+ }
39
+
40
+ return secrets;
41
+ } catch (error) {
42
+ logger.warn(`Warning: Could not read secrets file ${filePath}: ${error.message}`);
43
+ return {};
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Loads user secrets from ~/.aifabrix/secrets.local.yaml
49
+ * @function loadUserSecrets
50
+ * @returns {Object} Loaded secrets object or empty object
51
+ */
52
+ function loadUserSecrets() {
53
+ const userSecretsPath = path.join(os.homedir(), '.aifabrix', 'secrets.local.yaml');
54
+ if (!fs.existsSync(userSecretsPath)) {
55
+ return {};
56
+ }
57
+
58
+ try {
59
+ const content = fs.readFileSync(userSecretsPath, 'utf8');
60
+ const secrets = yaml.load(content);
61
+ if (!secrets || typeof secrets !== 'object') {
62
+ throw new Error(`Invalid secrets file format: ${userSecretsPath}`);
63
+ }
64
+ return secrets;
65
+ } catch (error) {
66
+ if (error.message.includes('Invalid secrets file format')) {
67
+ throw error;
68
+ }
69
+ logger.warn(`Warning: Could not read secrets file ${userSecretsPath}: ${error.message}`);
70
+ return {};
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Loads build secrets from variables.yaml and merges with existing secrets
76
+ * @async
77
+ * @function loadBuildSecrets
78
+ * @param {Object} mergedSecrets - Existing secrets to merge with
79
+ * @param {string} appName - Application name
80
+ * @returns {Promise<Object>} Merged secrets object
81
+ */
82
+ async function loadBuildSecrets(mergedSecrets, appName) {
83
+ const variablesPath = path.join(process.cwd(), 'builder', appName, 'variables.yaml');
84
+ if (!fs.existsSync(variablesPath)) {
85
+ return mergedSecrets;
86
+ }
87
+
88
+ try {
89
+ const variablesContent = fs.readFileSync(variablesPath, 'utf8');
90
+ const variables = yaml.load(variablesContent);
91
+
92
+ if (variables?.build?.secrets) {
93
+ const buildSecretsPath = path.resolve(
94
+ path.dirname(variablesPath),
95
+ variables.build.secrets
96
+ );
97
+
98
+ const buildSecrets = await loadSecretsFromFile(buildSecretsPath);
99
+
100
+ // Merge: user's file takes priority, but use build.secrets for missing/empty values
101
+ for (const [key, value] of Object.entries(buildSecrets)) {
102
+ if (!(key in mergedSecrets) || !mergedSecrets[key] || mergedSecrets[key] === '') {
103
+ mergedSecrets[key] = value;
104
+ }
105
+ }
106
+ }
107
+ } catch (error) {
108
+ logger.warn(`Warning: Could not load build.secrets from variables.yaml: ${error.message}`);
109
+ }
110
+
111
+ return mergedSecrets;
112
+ }
113
+
114
+ /**
115
+ * Loads default secrets from ~/.aifabrix/secrets.yaml
116
+ * @function loadDefaultSecrets
117
+ * @returns {Object} Loaded secrets object or empty object
118
+ */
119
+ function loadDefaultSecrets() {
120
+ const defaultPath = path.join(os.homedir(), '.aifabrix', 'secrets.yaml');
121
+ if (!fs.existsSync(defaultPath)) {
122
+ return {};
123
+ }
124
+
125
+ try {
126
+ const content = fs.readFileSync(defaultPath, 'utf8');
127
+ const secrets = yaml.load(content);
128
+ if (!secrets || typeof secrets !== 'object') {
129
+ throw new Error(`Invalid secrets file format: ${defaultPath}`);
130
+ }
131
+ return secrets;
132
+ } catch (error) {
133
+ if (error.message.includes('Invalid secrets file format')) {
134
+ throw error;
135
+ }
136
+ logger.warn(`Warning: Could not read secrets file ${defaultPath}: ${error.message}`);
137
+ return {};
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Builds a map of hostname to service name from environment config
143
+ * @function buildHostnameToServiceMap
144
+ * @param {Object} dockerHosts - Docker environment hosts configuration
145
+ * @returns {Object} Map of hostname to service name
146
+ */
147
+ function buildHostnameToServiceMap(dockerHosts) {
148
+ const hostnameToService = {};
149
+ for (const [key, hostname] of Object.entries(dockerHosts)) {
150
+ if (key.endsWith('_HOST')) {
151
+ // Use hostname directly as service name (e.g., 'keycloak', 'miso-controller')
152
+ hostnameToService[hostname] = hostname;
153
+ }
154
+ }
155
+ return hostnameToService;
156
+ }
157
+
158
+ /**
159
+ * Resolves port for a single URL by looking up service's variables.yaml
160
+ * @function resolveUrlPort
161
+ * @param {string} protocol - URL protocol (http:// or https://)
162
+ * @param {string} hostname - Service hostname
163
+ * @param {string} port - Current port
164
+ * @param {string} urlPath - URL path and query string
165
+ * @param {Object} hostnameToService - Map of hostname to service name
166
+ * @returns {string} URL with resolved port
167
+ */
168
+ function resolveUrlPort(protocol, hostname, port, urlPath, hostnameToService) {
169
+ const serviceName = hostnameToService[hostname];
170
+ if (!serviceName) {
171
+ // Not a service hostname, keep original
172
+ return `${protocol}${hostname}:${port}${urlPath}`;
173
+ }
174
+
175
+ // Try to load service's variables.yaml
176
+ const serviceVariablesPath = path.join(process.cwd(), 'builder', serviceName, 'variables.yaml');
177
+ if (!fs.existsSync(serviceVariablesPath)) {
178
+ // Service variables.yaml not found, keep original port
179
+ return `${protocol}${hostname}:${port}${urlPath}`;
180
+ }
181
+
182
+ try {
183
+ const variablesContent = fs.readFileSync(serviceVariablesPath, 'utf8');
184
+ const variables = yaml.load(variablesContent);
185
+
186
+ // Get containerPort or fall back to port
187
+ const containerPort = variables?.build?.containerPort || variables?.port || port;
188
+
189
+ // Replace port in URL
190
+ return `${protocol}${hostname}:${containerPort}${urlPath}`;
191
+ } catch (error) {
192
+ // Error loading variables.yaml, keep original port
193
+ logger.warn(`Warning: Could not load variables.yaml for service ${serviceName}: ${error.message}`);
194
+ return `${protocol}${hostname}:${port}${urlPath}`;
195
+ }
196
+ }
197
+
198
+ module.exports = {
199
+ loadSecretsFromFile,
200
+ loadUserSecrets,
201
+ loadBuildSecrets,
202
+ loadDefaultSecrets,
203
+ buildHostnameToServiceMap,
204
+ resolveUrlPort
205
+ };
206
+
@@ -0,0 +1,381 @@
1
+ /**
2
+ * AI Fabrix Builder Token Management Utilities
3
+ *
4
+ * Centralized token management for device and client credentials tokens
5
+ * Handles token retrieval, expiration checking, and refresh logic
6
+ *
7
+ * @fileoverview Token management utilities for AI Fabrix Builder
8
+ * @author AI Fabrix Team
9
+ * @version 2.0.0
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const os = require('os');
15
+ const yaml = require('js-yaml');
16
+ const config = require('../config');
17
+ const { makeApiCall, refreshDeviceToken: apiRefreshDeviceToken } = require('./api');
18
+ const logger = require('./logger');
19
+
20
+ const SECRETS_FILE = path.join(os.homedir(), '.aifabrix', 'secrets.local.yaml');
21
+
22
+ /**
23
+ * Load client credentials from secrets.local.yaml
24
+ * Reads using pattern: <app-name>-client-idKeyVault and <app-name>-client-secretKeyVault
25
+ * @param {string} appName - Application name
26
+ * @returns {Promise<{clientId: string, clientSecret: string}|null>} Credentials or null if not found
27
+ */
28
+ async function loadClientCredentials(appName) {
29
+ if (!appName || typeof appName !== 'string') {
30
+ throw new Error('App name is required and must be a string');
31
+ }
32
+
33
+ try {
34
+ if (!fs.existsSync(SECRETS_FILE)) {
35
+ return null;
36
+ }
37
+
38
+ const content = fs.readFileSync(SECRETS_FILE, 'utf8');
39
+ const secrets = yaml.load(content) || {};
40
+
41
+ const clientIdKey = `${appName}-client-idKeyVault`;
42
+ const clientSecretKey = `${appName}-client-secretKeyVault`;
43
+
44
+ const clientId = secrets[clientIdKey];
45
+ const clientSecret = secrets[clientSecretKey];
46
+
47
+ if (!clientId || !clientSecret) {
48
+ return null;
49
+ }
50
+
51
+ return {
52
+ clientId: clientId,
53
+ clientSecret: clientSecret
54
+ };
55
+ } catch (error) {
56
+ logger.warn(`Failed to load credentials from secrets.local.yaml: ${error.message}`);
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Get device token for controller
63
+ * @param {string} controllerUrl - Controller URL
64
+ * @returns {Promise<{controller: string, token: string, refreshToken: string, expiresAt: string}|null>} Device token info or null
65
+ */
66
+ async function getDeviceToken(controllerUrl) {
67
+ return await config.getDeviceToken(controllerUrl);
68
+ }
69
+
70
+ /**
71
+ * Get client token for environment and app
72
+ * @param {string} environment - Environment key
73
+ * @param {string} appName - Application name
74
+ * @returns {Promise<{controller: string, token: string, expiresAt: string}|null>} Client token info or null
75
+ */
76
+ async function getClientToken(environment, appName) {
77
+ return await config.getClientToken(environment, appName);
78
+ }
79
+
80
+ /**
81
+ * Check if token is expired
82
+ * @param {string} expiresAt - ISO timestamp string
83
+ * @returns {boolean} True if token is expired
84
+ */
85
+ function isTokenExpired(expiresAt) {
86
+ return config.isTokenExpired(expiresAt);
87
+ }
88
+
89
+ /**
90
+ * Refresh client token using credentials from secrets.local.yaml
91
+ * Gets new token from API and saves it to config.yaml
92
+ * @param {string} environment - Environment key
93
+ * @param {string} appName - Application name
94
+ * @param {string} controllerUrl - Controller URL
95
+ * @param {string} [clientId] - Optional client ID (if not provided, loads from secrets.local.yaml)
96
+ * @param {string} [clientSecret] - Optional client secret (if not provided, loads from secrets.local.yaml)
97
+ * @returns {Promise<{token: string, expiresAt: string}>} New token and expiration
98
+ * @throws {Error} If credentials are missing or token refresh fails
99
+ */
100
+ async function refreshClientToken(environment, appName, controllerUrl, clientId, clientSecret) {
101
+ if (!environment || typeof environment !== 'string') {
102
+ throw new Error('Environment is required and must be a string');
103
+ }
104
+ if (!appName || typeof appName !== 'string') {
105
+ throw new Error('App name is required and must be a string');
106
+ }
107
+ if (!controllerUrl || typeof controllerUrl !== 'string') {
108
+ throw new Error('Controller URL is required and must be a string');
109
+ }
110
+
111
+ // Load credentials if not provided
112
+ let credentials = null;
113
+ if (clientId && clientSecret) {
114
+ credentials = { clientId, clientSecret };
115
+ } else {
116
+ credentials = await loadClientCredentials(appName);
117
+ if (!credentials) {
118
+ throw new Error(`Client credentials not found for app '${appName}'. Add them to ~/.aifabrix/secrets.local.yaml as '${appName}-client-idKeyVault' and '${appName}-client-secretKeyVault'`);
119
+ }
120
+ }
121
+
122
+ // Call login API to get new token
123
+ const response = await makeApiCall(`${controllerUrl}/api/v1/auth/token`, {
124
+ method: 'POST',
125
+ headers: {
126
+ 'Content-Type': 'application/json',
127
+ 'x-client-id': credentials.clientId,
128
+ 'x-client-secret': credentials.clientSecret
129
+ }
130
+ });
131
+
132
+ if (!response.success) {
133
+ throw new Error(`Failed to refresh token: ${response.error || 'Unknown error'}`);
134
+ }
135
+
136
+ const responseData = response.data;
137
+ if (!responseData || !responseData.token) {
138
+ throw new Error('Invalid response: missing token');
139
+ }
140
+
141
+ const token = responseData.token;
142
+ // Calculate expiration (default to 24 hours if not provided)
143
+ const expiresIn = responseData.expiresIn || 86400;
144
+ const expiresAt = responseData.expiresAt || new Date(Date.now() + expiresIn * 1000).toISOString();
145
+
146
+ // Save token to config.yaml (NEVER save credentials)
147
+ await config.saveClientToken(environment, appName, controllerUrl, token, expiresAt);
148
+
149
+ return { token, expiresAt };
150
+ }
151
+
152
+ /**
153
+ * Get or refresh client token for environment and app
154
+ * Checks if token exists and is valid, refreshes if expired
155
+ * @param {string} environment - Environment key
156
+ * @param {string} appName - Application name
157
+ * @param {string} controllerUrl - Controller URL
158
+ * @returns {Promise<{token: string, controller: string}>} Token and controller URL
159
+ * @throws {Error} If token cannot be retrieved or refreshed
160
+ */
161
+ async function getOrRefreshClientToken(environment, appName, controllerUrl) {
162
+ // Try to get existing token
163
+ const tokenInfo = await getClientToken(environment, appName);
164
+
165
+ if (tokenInfo && tokenInfo.controller === controllerUrl && !isTokenExpired(tokenInfo.expiresAt)) {
166
+ // Token exists, is for correct controller, and is not expired
167
+ return {
168
+ token: tokenInfo.token,
169
+ controller: tokenInfo.controller
170
+ };
171
+ }
172
+
173
+ // Token missing or expired, refresh it
174
+ const refreshed = await refreshClientToken(environment, appName, controllerUrl);
175
+ return {
176
+ token: refreshed.token,
177
+ controller: controllerUrl
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Refresh device token using refresh token
183
+ * Calls API refresh endpoint and saves new token to config
184
+ * @param {string} controllerUrl - Controller URL
185
+ * @param {string} refreshToken - Refresh token
186
+ * @returns {Promise<{token: string, refreshToken: string, expiresAt: string}>} New token info
187
+ * @throws {Error} If refresh fails
188
+ */
189
+ async function refreshDeviceToken(controllerUrl, refreshToken) {
190
+ if (!controllerUrl || typeof controllerUrl !== 'string') {
191
+ throw new Error('Controller URL is required');
192
+ }
193
+ if (!refreshToken || typeof refreshToken !== 'string') {
194
+ throw new Error('Refresh token is required');
195
+ }
196
+
197
+ // Call API refresh endpoint
198
+ const tokenResponse = await apiRefreshDeviceToken(controllerUrl, refreshToken);
199
+
200
+ const token = tokenResponse.access_token;
201
+ const newRefreshToken = tokenResponse.refresh_token || refreshToken; // Use new refresh token if provided, otherwise keep old one
202
+ const expiresIn = tokenResponse.expires_in || 3600;
203
+ const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
204
+
205
+ // Save new token and refresh token to config
206
+ await config.saveDeviceToken(controllerUrl, token, newRefreshToken, expiresAt);
207
+
208
+ return {
209
+ token,
210
+ refreshToken: newRefreshToken,
211
+ expiresAt
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Get or refresh device token for controller
217
+ * Checks if token exists and is valid, refreshes if expired using refresh token
218
+ * @param {string} controllerUrl - Controller URL
219
+ * @returns {Promise<{token: string, controller: string}|null>} Token and controller URL, or null if not available
220
+ */
221
+ async function getOrRefreshDeviceToken(controllerUrl) {
222
+ // Try to get existing token
223
+ const tokenInfo = await getDeviceToken(controllerUrl);
224
+
225
+ if (!tokenInfo) {
226
+ return null;
227
+ }
228
+
229
+ // Check if token is expired
230
+ if (!isTokenExpired(tokenInfo.expiresAt)) {
231
+ // Token is valid
232
+ return {
233
+ token: tokenInfo.token,
234
+ controller: tokenInfo.controller
235
+ };
236
+ }
237
+
238
+ // Token is expired, try to refresh if refresh token exists
239
+ if (!tokenInfo.refreshToken) {
240
+ // No refresh token available
241
+ return null;
242
+ }
243
+
244
+ try {
245
+ const refreshed = await refreshDeviceToken(controllerUrl, tokenInfo.refreshToken);
246
+ return {
247
+ token: refreshed.token,
248
+ controller: controllerUrl
249
+ };
250
+ } catch (error) {
251
+ // Refresh failed, return null
252
+ logger.warn(`Failed to refresh device token: ${error.message}`);
253
+ return null;
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Get deployment authentication configuration with priority:
259
+ * 1. Device token (Bearer) - for user-level audit tracking (preferred)
260
+ * 2. Client token (Bearer) - for application-level authentication
261
+ * 3. Client credentials (x-client-id/x-client-secret) - direct credential authentication
262
+ *
263
+ * @param {string} controllerUrl - Controller URL
264
+ * @param {string} environment - Environment key
265
+ * @param {string} appName - Application name
266
+ * @returns {Promise<{type: 'bearer'|'credentials', token?: string, clientId?: string, clientSecret?: string, controller: string}>} Auth configuration
267
+ * @throws {Error} If no authentication method is available
268
+ */
269
+ async function getDeploymentAuth(controllerUrl, environment, appName) {
270
+ if (!controllerUrl || typeof controllerUrl !== 'string') {
271
+ throw new Error('Controller URL is required');
272
+ }
273
+ if (!environment || typeof environment !== 'string') {
274
+ throw new Error('Environment is required');
275
+ }
276
+ if (!appName || typeof appName !== 'string') {
277
+ throw new Error('App name is required');
278
+ }
279
+
280
+ // Priority 1: Try device token (for user-level audit)
281
+ const deviceToken = await getOrRefreshDeviceToken(controllerUrl);
282
+ if (deviceToken && deviceToken.token) {
283
+ return {
284
+ type: 'bearer',
285
+ token: deviceToken.token,
286
+ controller: deviceToken.controller
287
+ };
288
+ }
289
+
290
+ // Priority 2: Try client token (application-level)
291
+ try {
292
+ const clientToken = await getOrRefreshClientToken(environment, appName, controllerUrl);
293
+ if (clientToken && clientToken.token) {
294
+ return {
295
+ type: 'bearer',
296
+ token: clientToken.token,
297
+ controller: clientToken.controller
298
+ };
299
+ }
300
+ } catch (error) {
301
+ // Client token unavailable, continue to credentials
302
+ logger.warn(`Client token unavailable: ${error.message}`);
303
+ }
304
+
305
+ // Priority 3: Use client credentials directly
306
+ const credentials = await loadClientCredentials(appName);
307
+ if (credentials && credentials.clientId && credentials.clientSecret) {
308
+ return {
309
+ type: 'credentials',
310
+ clientId: credentials.clientId,
311
+ clientSecret: credentials.clientSecret,
312
+ controller: controllerUrl
313
+ };
314
+ }
315
+
316
+ throw new Error(`No authentication method available. Run 'aifabrix login' for device token, or add credentials to ~/.aifabrix/secrets.local.yaml as '${appName}-client-idKeyVault' and '${appName}-client-secretKeyVault'`);
317
+ }
318
+
319
+ /**
320
+ * Extracts client credentials from authConfig, loading from secrets if needed
321
+ * Used for validation and deployment endpoints that require clientId/clientSecret
322
+ * @async
323
+ * @param {Object} authConfig - Authentication configuration
324
+ * @param {string} appKey - Application key for loading credentials
325
+ * @param {string} envKey - Environment key
326
+ * @param {Object} options - Options with controllerId
327
+ * @returns {Promise<{clientId: string, clientSecret: string}>} Client credentials
328
+ * @throws {Error} If credentials cannot be obtained
329
+ */
330
+ async function extractClientCredentials(authConfig, appKey, envKey, _options = {}) {
331
+ if (authConfig.type === 'credentials') {
332
+ if (!authConfig.clientId || !authConfig.clientSecret) {
333
+ throw new Error('Client ID and Client Secret are required');
334
+ }
335
+ return {
336
+ clientId: authConfig.clientId,
337
+ clientSecret: authConfig.clientSecret
338
+ };
339
+ }
340
+
341
+ if (authConfig.type === 'bearer') {
342
+ if (authConfig.clientId && authConfig.clientSecret) {
343
+ return {
344
+ clientId: authConfig.clientId,
345
+ clientSecret: authConfig.clientSecret
346
+ };
347
+ }
348
+
349
+ // Try to load from secrets.local.yaml
350
+ const credentials = await loadClientCredentials(appKey);
351
+ if (credentials && credentials.clientId && credentials.clientSecret) {
352
+ // Store in authConfig so they're available for deployment step
353
+ authConfig.clientId = credentials.clientId;
354
+ authConfig.clientSecret = credentials.clientSecret;
355
+ return {
356
+ clientId: credentials.clientId,
357
+ clientSecret: credentials.clientSecret
358
+ };
359
+ }
360
+
361
+ // Construct clientId from controller, environment, and application key
362
+ // (not used, but shown in error message for reference)
363
+ throw new Error(`Client ID and Client Secret are required. Add credentials to ~/.aifabrix/secrets.local.yaml as '${appKey}-client-idKeyVault' and '${appKey}-client-secretKeyVault', or use credentials authentication.`);
364
+ }
365
+
366
+ throw new Error('Invalid authentication type');
367
+ }
368
+
369
+ module.exports = {
370
+ getDeviceToken,
371
+ getClientToken,
372
+ isTokenExpired,
373
+ refreshClientToken,
374
+ refreshDeviceToken,
375
+ loadClientCredentials,
376
+ getOrRefreshClientToken,
377
+ getOrRefreshDeviceToken,
378
+ getDeploymentAuth,
379
+ extractClientCredentials
380
+ };
381
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aifabrix/builder",
3
- "version": "2.1.6",
3
+ "version": "2.2.0",
4
4
  "description": "AI Fabrix Local Fabric & Deployment SDK",
5
5
  "main": "lib/index.js",
6
6
  "bin": {