@aifabrix/builder 2.1.7 ā 2.3.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.
- package/lib/app-deploy.js +73 -29
- package/lib/app-list.js +132 -0
- package/lib/app-readme.js +11 -4
- package/lib/app-register.js +435 -0
- package/lib/app-rotate-secret.js +164 -0
- package/lib/app-run.js +98 -84
- package/lib/app.js +13 -0
- package/lib/audit-logger.js +195 -15
- package/lib/build.js +155 -42
- package/lib/cli.js +104 -8
- package/lib/commands/app.js +8 -391
- package/lib/commands/login.js +130 -36
- package/lib/commands/secure.js +260 -0
- package/lib/config.js +315 -4
- package/lib/deployer.js +221 -183
- package/lib/infra.js +177 -112
- package/lib/push.js +34 -7
- package/lib/secrets.js +89 -23
- package/lib/templates.js +1 -1
- package/lib/utils/api-error-handler.js +465 -0
- package/lib/utils/api.js +165 -16
- package/lib/utils/auth-headers.js +84 -0
- package/lib/utils/build-copy.js +162 -0
- package/lib/utils/cli-utils.js +49 -3
- package/lib/utils/compose-generator.js +57 -16
- package/lib/utils/deployment-errors.js +90 -0
- package/lib/utils/deployment-validation.js +60 -0
- package/lib/utils/dev-config.js +83 -0
- package/lib/utils/docker-build.js +24 -0
- package/lib/utils/env-template.js +30 -10
- package/lib/utils/health-check.js +18 -1
- package/lib/utils/infra-containers.js +101 -0
- package/lib/utils/local-secrets.js +0 -2
- package/lib/utils/secrets-encryption.js +203 -0
- package/lib/utils/secrets-path.js +22 -3
- package/lib/utils/token-manager.js +381 -0
- package/package.json +2 -2
- package/templates/applications/README.md.hbs +155 -23
- package/templates/applications/miso-controller/Dockerfile +7 -119
- package/templates/infra/compose.yaml.hbs +93 -0
- package/templates/python/docker-compose.hbs +25 -17
- package/templates/typescript/docker-compose.hbs +25 -17
- package/test-output.txt +0 -5431
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Fabrix Builder - App Register Command
|
|
3
|
+
*
|
|
4
|
+
* Handles application registration and credential generation
|
|
5
|
+
*
|
|
6
|
+
* @fileoverview App register command implementation for AI Fabrix Builder
|
|
7
|
+
* @author AI Fabrix Team
|
|
8
|
+
* @version 2.0.0
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs').promises;
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const chalk = require('chalk');
|
|
14
|
+
const yaml = require('js-yaml');
|
|
15
|
+
const { getConfig } = require('./config');
|
|
16
|
+
const { authenticatedApiCall } = require('./utils/api');
|
|
17
|
+
const { formatApiError } = require('./utils/api-error-handler');
|
|
18
|
+
const logger = require('./utils/logger');
|
|
19
|
+
const { saveLocalSecret, isLocalhost } = require('./utils/local-secrets');
|
|
20
|
+
const { updateEnvTemplate } = require('./utils/env-template');
|
|
21
|
+
const { getOrRefreshDeviceToken } = require('./utils/token-manager');
|
|
22
|
+
|
|
23
|
+
// Import createApp to auto-generate config if missing
|
|
24
|
+
let createApp;
|
|
25
|
+
try {
|
|
26
|
+
createApp = require('./app').createApp;
|
|
27
|
+
} catch {
|
|
28
|
+
createApp = null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Validation schema for application registration
|
|
33
|
+
*/
|
|
34
|
+
const registerApplicationSchema = {
|
|
35
|
+
environmentId: (val) => {
|
|
36
|
+
if (!val || val.length < 1) {
|
|
37
|
+
throw new Error('Invalid environment ID format');
|
|
38
|
+
}
|
|
39
|
+
return val;
|
|
40
|
+
},
|
|
41
|
+
key: (val) => {
|
|
42
|
+
if (!val || val.length < 1) {
|
|
43
|
+
throw new Error('Application key is required');
|
|
44
|
+
}
|
|
45
|
+
if (val.length > 50) {
|
|
46
|
+
throw new Error('Application key must be at most 50 characters');
|
|
47
|
+
}
|
|
48
|
+
if (!/^[a-z0-9-]+$/.test(val)) {
|
|
49
|
+
throw new Error('Application key must contain only lowercase letters, numbers, and hyphens');
|
|
50
|
+
}
|
|
51
|
+
return val;
|
|
52
|
+
},
|
|
53
|
+
displayName: (val) => {
|
|
54
|
+
if (!val || val.length < 1) {
|
|
55
|
+
throw new Error('Display name is required');
|
|
56
|
+
}
|
|
57
|
+
if (val.length > 100) {
|
|
58
|
+
throw new Error('Display name must be at most 100 characters');
|
|
59
|
+
}
|
|
60
|
+
return val;
|
|
61
|
+
},
|
|
62
|
+
description: (val) => val || undefined,
|
|
63
|
+
configuration: (val) => {
|
|
64
|
+
const validTypes = ['webapp', 'api', 'service', 'functionapp'];
|
|
65
|
+
const validRegistryModes = ['acr', 'external', 'public'];
|
|
66
|
+
|
|
67
|
+
if (!val || !val.type || !validTypes.includes(val.type)) {
|
|
68
|
+
throw new Error('Configuration type must be one of: webapp, api, service, functionapp');
|
|
69
|
+
}
|
|
70
|
+
if (!val.registryMode || !validRegistryModes.includes(val.registryMode)) {
|
|
71
|
+
throw new Error('Registry mode must be one of: acr, external, public');
|
|
72
|
+
}
|
|
73
|
+
if (val.port !== undefined) {
|
|
74
|
+
if (!Number.isInteger(val.port) || val.port < 1 || val.port > 65535) {
|
|
75
|
+
throw new Error('Port must be an integer between 1 and 65535');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return val;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Load variables.yaml file for an application
|
|
84
|
+
* @async
|
|
85
|
+
* @param {string} appKey - Application key
|
|
86
|
+
* @returns {Promise<{variables: Object, created: boolean}>} Variables and creation flag
|
|
87
|
+
*/
|
|
88
|
+
async function loadVariablesYaml(appKey) {
|
|
89
|
+
const variablesPath = path.join(process.cwd(), 'builder', appKey, 'variables.yaml');
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const variablesContent = await fs.readFile(variablesPath, 'utf-8');
|
|
93
|
+
return { variables: yaml.load(variablesContent), created: false };
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error.code === 'ENOENT') {
|
|
96
|
+
logger.log(chalk.yellow(`ā ļø variables.yaml not found for ${appKey}`));
|
|
97
|
+
logger.log(chalk.yellow('š Creating minimal configuration...\n'));
|
|
98
|
+
return { variables: null, created: true };
|
|
99
|
+
}
|
|
100
|
+
throw new Error(`Failed to read variables.yaml: ${error.message}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Create minimal application configuration if needed
|
|
106
|
+
* @async
|
|
107
|
+
* @param {string} appKey - Application key
|
|
108
|
+
* @param {Object} options - Registration options
|
|
109
|
+
* @returns {Promise<Object>} Variables after creation
|
|
110
|
+
*/
|
|
111
|
+
async function createMinimalAppIfNeeded(appKey, options) {
|
|
112
|
+
if (!createApp) {
|
|
113
|
+
throw new Error('Cannot auto-create application: createApp function not available');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await createApp(appKey, {
|
|
117
|
+
port: options.port,
|
|
118
|
+
language: 'typescript',
|
|
119
|
+
database: false,
|
|
120
|
+
redis: false,
|
|
121
|
+
storage: false,
|
|
122
|
+
authentication: false
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const variablesPath = path.join(process.cwd(), 'builder', appKey, 'variables.yaml');
|
|
126
|
+
const variablesContent = await fs.readFile(variablesPath, 'utf-8');
|
|
127
|
+
return yaml.load(variablesContent);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Extract application configuration from variables.yaml
|
|
132
|
+
* @param {Object} variables - Variables from YAML file
|
|
133
|
+
* @param {string} appKey - Application key
|
|
134
|
+
* @param {Object} options - Registration options
|
|
135
|
+
* @returns {Object} Extracted configuration
|
|
136
|
+
*/
|
|
137
|
+
function extractAppConfiguration(variables, appKey, options) {
|
|
138
|
+
const appKeyFromFile = variables.app?.key || appKey;
|
|
139
|
+
const displayName = variables.app?.name || options.name || appKey;
|
|
140
|
+
const description = variables.app?.description || '';
|
|
141
|
+
const appType = variables.build?.language === 'typescript' ? 'webapp' : 'service';
|
|
142
|
+
const registryMode = 'external';
|
|
143
|
+
const port = variables.build?.port || options.port || 3000;
|
|
144
|
+
const language = variables.build?.language || 'typescript';
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
appKey: appKeyFromFile,
|
|
148
|
+
displayName,
|
|
149
|
+
description,
|
|
150
|
+
appType,
|
|
151
|
+
registryMode,
|
|
152
|
+
port,
|
|
153
|
+
language
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Validate application registration data
|
|
159
|
+
* @param {Object} config - Application configuration
|
|
160
|
+
* @param {string} originalAppKey - Original app key for error messages
|
|
161
|
+
* @throws {Error} If validation fails
|
|
162
|
+
*/
|
|
163
|
+
function validateAppRegistrationData(config, originalAppKey) {
|
|
164
|
+
const missingFields = [];
|
|
165
|
+
if (!config.appKey) missingFields.push('app.key');
|
|
166
|
+
if (!config.displayName) missingFields.push('app.name');
|
|
167
|
+
|
|
168
|
+
if (missingFields.length > 0) {
|
|
169
|
+
logger.error(chalk.red('ā Missing required fields in variables.yaml:'));
|
|
170
|
+
missingFields.forEach(field => logger.error(chalk.red(` - ${field}`)));
|
|
171
|
+
logger.error(chalk.red(`\n Please update builder/${originalAppKey}/variables.yaml and try again.`));
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
registerApplicationSchema.key(config.appKey);
|
|
177
|
+
registerApplicationSchema.displayName(config.displayName);
|
|
178
|
+
registerApplicationSchema.configuration({
|
|
179
|
+
type: config.appType,
|
|
180
|
+
registryMode: config.registryMode,
|
|
181
|
+
port: config.port
|
|
182
|
+
});
|
|
183
|
+
} catch (error) {
|
|
184
|
+
logger.error(chalk.red(`ā Invalid configuration: ${error.message}`));
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Check if user is authenticated and get token
|
|
191
|
+
* @async
|
|
192
|
+
* @param {string} [controllerUrl] - Optional controller URL from variables.yaml
|
|
193
|
+
* @param {string} [environment] - Optional environment key
|
|
194
|
+
* @returns {Promise<{apiUrl: string, token: string}>} Configuration with API URL and token
|
|
195
|
+
*/
|
|
196
|
+
async function checkAuthentication(controllerUrl, environment) {
|
|
197
|
+
const config = await getConfig();
|
|
198
|
+
|
|
199
|
+
// Try to get controller URL from parameter, config, or device tokens
|
|
200
|
+
let finalControllerUrl = controllerUrl;
|
|
201
|
+
let token = null;
|
|
202
|
+
|
|
203
|
+
// If controller URL provided, try to get device token
|
|
204
|
+
if (finalControllerUrl) {
|
|
205
|
+
const deviceToken = await getOrRefreshDeviceToken(finalControllerUrl);
|
|
206
|
+
if (deviceToken && deviceToken.token) {
|
|
207
|
+
token = deviceToken.token;
|
|
208
|
+
finalControllerUrl = deviceToken.controller;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// If no token yet, try to find any device token in config
|
|
213
|
+
if (!token && config.device) {
|
|
214
|
+
const deviceUrls = Object.keys(config.device);
|
|
215
|
+
if (deviceUrls.length > 0) {
|
|
216
|
+
// Use first available device token
|
|
217
|
+
finalControllerUrl = deviceUrls[0];
|
|
218
|
+
const deviceToken = await getOrRefreshDeviceToken(finalControllerUrl);
|
|
219
|
+
if (deviceToken && deviceToken.token) {
|
|
220
|
+
token = deviceToken.token;
|
|
221
|
+
finalControllerUrl = deviceToken.controller;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// If still no token, check for client token (requires environment and app)
|
|
227
|
+
if (!token && environment) {
|
|
228
|
+
// For app register, we don't have an app yet, so client tokens won't work
|
|
229
|
+
// This is expected - device tokens should be used for registration
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!token || !finalControllerUrl) {
|
|
233
|
+
logger.error(chalk.red('ā Not logged in. Run: aifabrix login'));
|
|
234
|
+
logger.error(chalk.gray(' Use device code flow: aifabrix login --method device --controller <url>'));
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
apiUrl: finalControllerUrl,
|
|
240
|
+
token: token
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Call registration API
|
|
246
|
+
* @async
|
|
247
|
+
* @param {string} apiUrl - API URL
|
|
248
|
+
* @param {string} token - Authentication token
|
|
249
|
+
* @param {string} environment - Environment ID
|
|
250
|
+
* @param {Object} registrationData - Registration data
|
|
251
|
+
* @returns {Promise<Object>} API response
|
|
252
|
+
*/
|
|
253
|
+
async function callRegisterApi(apiUrl, token, environment, registrationData) {
|
|
254
|
+
const response = await authenticatedApiCall(
|
|
255
|
+
`${apiUrl}/api/v1/environments/${encodeURIComponent(environment)}/applications/register`,
|
|
256
|
+
{
|
|
257
|
+
method: 'POST',
|
|
258
|
+
body: JSON.stringify(registrationData)
|
|
259
|
+
},
|
|
260
|
+
token
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
if (!response.success) {
|
|
264
|
+
const formattedError = response.formattedError || formatApiError(response);
|
|
265
|
+
logger.error(formattedError);
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Handle API response structure:
|
|
270
|
+
// makeApiCall returns: { success: true, data: <API response> }
|
|
271
|
+
// API response can be:
|
|
272
|
+
// 1. Direct format: { application: {...}, credentials: {...} }
|
|
273
|
+
// 2. Wrapped format: { success: true, data: { application: {...}, credentials: {...} } }
|
|
274
|
+
const apiResponse = response.data;
|
|
275
|
+
if (apiResponse && apiResponse.data && apiResponse.data.application) {
|
|
276
|
+
// Wrapped format: use apiResponse.data
|
|
277
|
+
return apiResponse.data;
|
|
278
|
+
} else if (apiResponse && apiResponse.application) {
|
|
279
|
+
// Direct format: use apiResponse directly
|
|
280
|
+
return apiResponse;
|
|
281
|
+
}
|
|
282
|
+
// Fallback: return apiResponse as-is (shouldn't happen, but handle gracefully)
|
|
283
|
+
logger.error(chalk.red('ā Invalid response: missing application data'));
|
|
284
|
+
logger.error(chalk.gray('\nFull response for debugging:'));
|
|
285
|
+
logger.error(chalk.gray(JSON.stringify(response, null, 2)));
|
|
286
|
+
process.exit(1);
|
|
287
|
+
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Get environment prefix for GitHub Secrets
|
|
292
|
+
* @param {string} environment - Environment key (e.g., 'dev', 'tst', 'pro', 'miso')
|
|
293
|
+
* @returns {string} Uppercase prefix (e.g., 'DEV', 'TST', 'PRO', 'MISO')
|
|
294
|
+
*/
|
|
295
|
+
function getEnvironmentPrefix(environment) {
|
|
296
|
+
if (!environment) {
|
|
297
|
+
return 'DEV';
|
|
298
|
+
}
|
|
299
|
+
// Convert to uppercase and handle common variations
|
|
300
|
+
const env = environment.toLowerCase();
|
|
301
|
+
if (env === 'dev' || env === 'development') {
|
|
302
|
+
return 'DEV';
|
|
303
|
+
}
|
|
304
|
+
if (env === 'tst' || env === 'test' || env === 'staging') {
|
|
305
|
+
return 'TST';
|
|
306
|
+
}
|
|
307
|
+
if (env === 'pro' || env === 'prod' || env === 'production') {
|
|
308
|
+
return 'PRO';
|
|
309
|
+
}
|
|
310
|
+
// For other environments (e.g., 'miso'), uppercase the entire string
|
|
311
|
+
// Use full string if 4 characters or less, otherwise use first 4 characters
|
|
312
|
+
const upper = environment.toUpperCase();
|
|
313
|
+
return upper.length <= 4 ? upper : upper.substring(0, 4);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Display registration success and credentials
|
|
318
|
+
* @param {Object} data - Registration response data
|
|
319
|
+
* @param {string} apiUrl - API URL
|
|
320
|
+
* @param {string} environment - Environment key
|
|
321
|
+
*/
|
|
322
|
+
function displayRegistrationResults(data, apiUrl, environment) {
|
|
323
|
+
logger.log(chalk.green('ā
Application registered successfully!\n'));
|
|
324
|
+
logger.log(chalk.bold('š Application Details:'));
|
|
325
|
+
logger.log(` ID: ${data.application.id}`);
|
|
326
|
+
logger.log(` Key: ${data.application.key}`);
|
|
327
|
+
logger.log(` Display Name: ${data.application.displayName}\n`);
|
|
328
|
+
|
|
329
|
+
logger.log(chalk.bold.yellow('š CREDENTIALS (save these immediately):'));
|
|
330
|
+
logger.log(chalk.yellow(` Client ID: ${data.credentials.clientId}`));
|
|
331
|
+
logger.log(chalk.yellow(` Client Secret: ${data.credentials.clientSecret}\n`));
|
|
332
|
+
|
|
333
|
+
logger.log(chalk.red('ā ļø IMPORTANT: Client Secret will not be shown again!\n'));
|
|
334
|
+
|
|
335
|
+
const envPrefix = getEnvironmentPrefix(environment);
|
|
336
|
+
logger.log(chalk.bold('š Add to GitHub Secrets:'));
|
|
337
|
+
logger.log(chalk.cyan(' Repository level:'));
|
|
338
|
+
logger.log(chalk.cyan(` MISO_CONTROLLER_URL = ${apiUrl}`));
|
|
339
|
+
logger.log(chalk.cyan(`\n Environment level (${environment}):`));
|
|
340
|
+
logger.log(chalk.cyan(` ${envPrefix}_MISO_CLIENTID = ${data.credentials.clientId}`));
|
|
341
|
+
logger.log(chalk.cyan(` ${envPrefix}_MISO_CLIENTSECRET = ${data.credentials.clientSecret}\n`));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Register an application
|
|
346
|
+
* @async
|
|
347
|
+
* @param {string} appKey - Application key
|
|
348
|
+
* @param {Object} options - Registration options
|
|
349
|
+
* @param {string} options.environment - Environment ID or key
|
|
350
|
+
* @param {number} [options.port] - Application port
|
|
351
|
+
* @param {string} [options.name] - Override display name
|
|
352
|
+
* @param {string} [options.description] - Override description
|
|
353
|
+
* @throws {Error} If registration fails
|
|
354
|
+
*/
|
|
355
|
+
async function registerApplication(appKey, options) {
|
|
356
|
+
logger.log(chalk.blue('š Registering application...\n'));
|
|
357
|
+
|
|
358
|
+
// Load variables.yaml
|
|
359
|
+
const { variables, created } = await loadVariablesYaml(appKey);
|
|
360
|
+
let finalVariables = variables;
|
|
361
|
+
|
|
362
|
+
// Create minimal app if needed
|
|
363
|
+
if (created) {
|
|
364
|
+
finalVariables = await createMinimalAppIfNeeded(appKey, options);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Extract configuration
|
|
368
|
+
const appConfig = extractAppConfiguration(finalVariables, appKey, options);
|
|
369
|
+
|
|
370
|
+
// Validate configuration (pass original appKey for error messages)
|
|
371
|
+
validateAppRegistrationData(appConfig, appKey);
|
|
372
|
+
|
|
373
|
+
// Get controller URL from variables.yaml if available
|
|
374
|
+
const controllerUrl = finalVariables?.deployment?.controllerUrl;
|
|
375
|
+
|
|
376
|
+
// Check authentication (try device token first, supports registration flow)
|
|
377
|
+
const authConfig = await checkAuthentication(controllerUrl, options.environment);
|
|
378
|
+
|
|
379
|
+
// Validate environment
|
|
380
|
+
const environment = registerApplicationSchema.environmentId(options.environment);
|
|
381
|
+
|
|
382
|
+
// Prepare registration data to match OpenAPI RegisterApplicationRequest schema
|
|
383
|
+
// Schema: { key, displayName, description?, configuration: { type, registryMode, port?, image? } }
|
|
384
|
+
const registrationData = {
|
|
385
|
+
key: appConfig.appKey,
|
|
386
|
+
displayName: appConfig.displayName,
|
|
387
|
+
configuration: {
|
|
388
|
+
type: appConfig.appType,
|
|
389
|
+
registryMode: appConfig.registryMode
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
// Add optional fields only if they have values
|
|
394
|
+
if (appConfig.description || options.description) {
|
|
395
|
+
registrationData.description = appConfig.description || options.description;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (appConfig.port) {
|
|
399
|
+
registrationData.configuration.port = appConfig.port;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Register application
|
|
403
|
+
const responseData = await callRegisterApi(
|
|
404
|
+
authConfig.apiUrl,
|
|
405
|
+
authConfig.token,
|
|
406
|
+
environment,
|
|
407
|
+
registrationData
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
// Save credentials to local secrets if localhost
|
|
411
|
+
if (isLocalhost(authConfig.apiUrl)) {
|
|
412
|
+
const registeredAppKey = responseData.application.key;
|
|
413
|
+
const clientIdKey = `${registeredAppKey}-client-idKeyVault`;
|
|
414
|
+
const clientSecretKey = `${registeredAppKey}-client-secretKeyVault`;
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
await saveLocalSecret(clientIdKey, responseData.credentials.clientId);
|
|
418
|
+
await saveLocalSecret(clientSecretKey, responseData.credentials.clientSecret);
|
|
419
|
+
|
|
420
|
+
// Update env.template
|
|
421
|
+
await updateEnvTemplate(registeredAppKey, clientIdKey, clientSecretKey, authConfig.apiUrl);
|
|
422
|
+
|
|
423
|
+
logger.log(chalk.green('\nā Credentials saved to ~/.aifabrix/secrets.local.yaml'));
|
|
424
|
+
logger.log(chalk.green('ā env.template updated with MISO_CLIENTID, MISO_CLIENTSECRET, and MISO_CONTROLLER_URL\n'));
|
|
425
|
+
} catch (error) {
|
|
426
|
+
logger.warn(chalk.yellow(`ā ļø Could not save credentials locally: ${error.message}`));
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Display results
|
|
431
|
+
displayRegistrationResults(responseData, authConfig.apiUrl, environment);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
module.exports = { registerApplication, getEnvironmentPrefix };
|
|
435
|
+
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Fabrix Builder - App Rotate Secret Command
|
|
3
|
+
*
|
|
4
|
+
* Handles rotating pipeline ClientSecret for an application
|
|
5
|
+
*
|
|
6
|
+
* @fileoverview App rotate-secret command implementation for AI Fabrix Builder
|
|
7
|
+
* @author AI Fabrix Team
|
|
8
|
+
* @version 2.0.0
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const chalk = require('chalk');
|
|
12
|
+
const { getConfig } = require('./config');
|
|
13
|
+
const { getOrRefreshDeviceToken } = require('./utils/token-manager');
|
|
14
|
+
const { authenticatedApiCall } = require('./utils/api');
|
|
15
|
+
const { formatApiError } = require('./utils/api-error-handler');
|
|
16
|
+
const logger = require('./utils/logger');
|
|
17
|
+
const { saveLocalSecret, isLocalhost } = require('./utils/local-secrets');
|
|
18
|
+
const { updateEnvTemplate } = require('./utils/env-template');
|
|
19
|
+
const { getEnvironmentPrefix } = require('./app-register');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validate environment parameter
|
|
23
|
+
* @param {string} environment - Environment ID or key
|
|
24
|
+
* @throws {Error} If environment is invalid
|
|
25
|
+
*/
|
|
26
|
+
function validateEnvironment(environment) {
|
|
27
|
+
if (!environment || environment.length < 1) {
|
|
28
|
+
throw new Error('Environment is required');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate API response structure
|
|
34
|
+
* @param {Object} response - API response
|
|
35
|
+
* @throws {Error} If response structure is invalid
|
|
36
|
+
*/
|
|
37
|
+
function validateResponse(response) {
|
|
38
|
+
if (!response.data || typeof response.data !== 'object') {
|
|
39
|
+
throw new Error('Invalid response: missing data');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const credentials = response.data.credentials;
|
|
43
|
+
if (!credentials || typeof credentials !== 'object' || typeof credentials.clientId !== 'string' || typeof credentials.clientSecret !== 'string') {
|
|
44
|
+
throw new Error('Invalid response: missing or invalid credentials');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Display rotation results
|
|
50
|
+
* @param {string} appKey - Application key
|
|
51
|
+
* @param {string} environment - Environment ID or key
|
|
52
|
+
* @param {Object} credentials - New credentials
|
|
53
|
+
* @param {string} apiUrl - API URL
|
|
54
|
+
* @param {string} [message] - Optional message from API
|
|
55
|
+
*/
|
|
56
|
+
function displayRotationResults(appKey, environment, credentials, apiUrl, message) {
|
|
57
|
+
logger.log(chalk.green('ā
Secret rotated successfully!\n'));
|
|
58
|
+
logger.log(chalk.bold('š Application Details:'));
|
|
59
|
+
logger.log(` Key: ${appKey}`);
|
|
60
|
+
logger.log(` Environment: ${environment}\n`);
|
|
61
|
+
|
|
62
|
+
logger.log(chalk.bold.yellow('š NEW CREDENTIALS:'));
|
|
63
|
+
logger.log(chalk.yellow(` Client ID: ${credentials.clientId}`));
|
|
64
|
+
logger.log(chalk.yellow(` Client Secret: ${credentials.clientSecret}\n`));
|
|
65
|
+
|
|
66
|
+
const envPrefix = getEnvironmentPrefix(environment);
|
|
67
|
+
logger.log(chalk.bold('š Update GitHub Secrets:'));
|
|
68
|
+
logger.log(chalk.cyan(' Repository level:'));
|
|
69
|
+
logger.log(chalk.cyan(` MISO_CONTROLLER_URL = ${apiUrl}`));
|
|
70
|
+
logger.log(chalk.cyan(`\n Environment level (${environment}):`));
|
|
71
|
+
logger.log(chalk.cyan(` ${envPrefix}_MISO_CLIENTID = ${credentials.clientId}`));
|
|
72
|
+
logger.log(chalk.cyan(` ${envPrefix}_MISO_CLIENTSECRET = ${credentials.clientSecret}\n`));
|
|
73
|
+
|
|
74
|
+
if (message) {
|
|
75
|
+
logger.log(chalk.gray(` ${message}\n`));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Rotate secret for an application
|
|
81
|
+
* @async
|
|
82
|
+
* @param {string} appKey - Application key
|
|
83
|
+
* @param {Object} options - Command options
|
|
84
|
+
* @param {string} options.environment - Environment ID or key
|
|
85
|
+
* @throws {Error} If rotation fails
|
|
86
|
+
*/
|
|
87
|
+
async function rotateSecret(appKey, options) {
|
|
88
|
+
logger.log(chalk.yellow('ā ļø This will invalidate the old ClientSecret!\n'));
|
|
89
|
+
|
|
90
|
+
const config = await getConfig();
|
|
91
|
+
|
|
92
|
+
// Try to get device token
|
|
93
|
+
let controllerUrl = null;
|
|
94
|
+
let token = null;
|
|
95
|
+
|
|
96
|
+
if (config.device) {
|
|
97
|
+
const deviceUrls = Object.keys(config.device);
|
|
98
|
+
if (deviceUrls.length > 0) {
|
|
99
|
+
controllerUrl = deviceUrls[0];
|
|
100
|
+
const deviceToken = await getOrRefreshDeviceToken(controllerUrl);
|
|
101
|
+
if (deviceToken && deviceToken.token) {
|
|
102
|
+
token = deviceToken.token;
|
|
103
|
+
controllerUrl = deviceToken.controller;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!token || !controllerUrl) {
|
|
109
|
+
logger.error(chalk.red('ā Not logged in. Run: aifabrix login'));
|
|
110
|
+
logger.error(chalk.gray(' Use device code flow: aifabrix login --method device --controller <url>'));
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Validate environment
|
|
115
|
+
validateEnvironment(options.environment);
|
|
116
|
+
|
|
117
|
+
// OpenAPI spec: POST /api/v1/environments/{envKey}/applications/{appKey}/rotate-secret
|
|
118
|
+
// Path parameters: envKey, appKey (no query parameters)
|
|
119
|
+
const response = await authenticatedApiCall(
|
|
120
|
+
`${controllerUrl}/api/v1/environments/${encodeURIComponent(options.environment)}/applications/${encodeURIComponent(appKey)}/rotate-secret`,
|
|
121
|
+
{
|
|
122
|
+
method: 'POST'
|
|
123
|
+
},
|
|
124
|
+
token
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
if (!response.success) {
|
|
128
|
+
const formattedError = response.formattedError || formatApiError(response);
|
|
129
|
+
logger.error(formattedError);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Validate response structure
|
|
134
|
+
validateResponse(response);
|
|
135
|
+
|
|
136
|
+
const credentials = response.data.credentials;
|
|
137
|
+
const message = response.data.message;
|
|
138
|
+
|
|
139
|
+
// Save credentials to local secrets (always save when rotating)
|
|
140
|
+
const clientIdKey = `${appKey}-client-idKeyVault`;
|
|
141
|
+
const clientSecretKey = `${appKey}-client-secretKeyVault`;
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
await saveLocalSecret(clientIdKey, credentials.clientId);
|
|
145
|
+
await saveLocalSecret(clientSecretKey, credentials.clientSecret);
|
|
146
|
+
|
|
147
|
+
// Update env.template if localhost
|
|
148
|
+
if (isLocalhost(controllerUrl)) {
|
|
149
|
+
await updateEnvTemplate(appKey, clientIdKey, clientSecretKey, controllerUrl);
|
|
150
|
+
logger.log(chalk.green('\nā Credentials saved to ~/.aifabrix/secrets.local.yaml'));
|
|
151
|
+
logger.log(chalk.green('ā env.template updated with MISO_CLIENTID, MISO_CLIENTSECRET, and MISO_CONTROLLER_URL\n'));
|
|
152
|
+
} else {
|
|
153
|
+
logger.log(chalk.green('\nā Credentials saved to ~/.aifabrix/secrets.local.yaml\n'));
|
|
154
|
+
}
|
|
155
|
+
} catch (error) {
|
|
156
|
+
logger.warn(chalk.yellow(`ā ļø Could not save credentials locally: ${error.message}`));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Display results
|
|
160
|
+
displayRotationResults(appKey, options.environment, credentials, controllerUrl, message);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { rotateSecret };
|
|
164
|
+
|