@aifabrix/builder 2.0.0 ā 2.0.2
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/README.md +6 -2
- package/bin/aifabrix.js +9 -3
- package/jest.config.integration.js +30 -0
- package/lib/app-config.js +157 -0
- package/lib/app-deploy.js +233 -82
- package/lib/app-dockerfile.js +112 -0
- package/lib/app-prompts.js +244 -0
- package/lib/app-push.js +172 -0
- package/lib/app-run.js +334 -133
- package/lib/app.js +208 -274
- package/lib/audit-logger.js +2 -0
- package/lib/build.js +209 -98
- package/lib/cli.js +76 -86
- package/lib/commands/app.js +414 -0
- package/lib/commands/login.js +304 -0
- package/lib/config.js +78 -0
- package/lib/deployer.js +225 -81
- package/lib/env-reader.js +45 -30
- package/lib/generator.js +308 -191
- package/lib/github-generator.js +67 -7
- package/lib/infra.js +156 -61
- package/lib/push.js +105 -10
- package/lib/schema/application-schema.json +30 -2
- package/lib/schema/infrastructure-schema.json +589 -0
- package/lib/secrets.js +229 -24
- package/lib/template-validator.js +205 -0
- package/lib/templates.js +305 -170
- package/lib/utils/api.js +329 -0
- package/lib/utils/cli-utils.js +97 -0
- package/lib/utils/dockerfile-utils.js +131 -0
- package/lib/utils/environment-checker.js +125 -0
- package/lib/utils/error-formatter.js +61 -0
- package/lib/utils/health-check.js +187 -0
- package/lib/utils/logger.js +53 -0
- package/lib/utils/template-helpers.js +223 -0
- package/lib/utils/variable-transformer.js +271 -0
- package/lib/validator.js +27 -112
- package/package.json +13 -10
- package/templates/README.md +75 -3
- package/templates/applications/keycloak/Dockerfile +36 -0
- package/templates/applications/keycloak/env.template +32 -0
- package/templates/applications/keycloak/rbac.yaml +37 -0
- package/templates/applications/keycloak/variables.yaml +56 -0
- package/templates/applications/miso-controller/Dockerfile +125 -0
- package/templates/applications/miso-controller/env.template +129 -0
- package/templates/applications/miso-controller/rbac.yaml +168 -0
- package/templates/applications/miso-controller/variables.yaml +56 -0
- package/templates/github/release.yaml.hbs +5 -26
- package/templates/github/steps/npm.hbs +24 -0
- package/templates/infra/compose.yaml +6 -6
- package/templates/python/docker-compose.hbs +19 -12
- package/templates/python/main.py +80 -0
- package/templates/python/requirements.txt +4 -0
- package/templates/typescript/Dockerfile.hbs +2 -2
- package/templates/typescript/docker-compose.hbs +19 -12
- package/templates/typescript/index.ts +116 -0
- package/templates/typescript/package.json +26 -0
- package/templates/typescript/tsconfig.json +24 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Fabrix Builder - Application Registration Commands
|
|
3
|
+
*
|
|
4
|
+
* Handles application registration, listing, and credential rotation
|
|
5
|
+
* Commands: app register, app list, app rotate-secret
|
|
6
|
+
*
|
|
7
|
+
* @fileoverview Application management commands for AI Fabrix Builder
|
|
8
|
+
* @author AI Fabrix Team
|
|
9
|
+
* @version 2.0.0
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs').promises;
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const chalk = require('chalk');
|
|
15
|
+
const yaml = require('js-yaml');
|
|
16
|
+
const { getConfig } = require('../config');
|
|
17
|
+
const { authenticatedApiCall } = require('../utils/api');
|
|
18
|
+
const logger = require('../utils/logger');
|
|
19
|
+
|
|
20
|
+
// Import createApp to auto-generate config if missing
|
|
21
|
+
let createApp;
|
|
22
|
+
try {
|
|
23
|
+
createApp = require('../app').createApp;
|
|
24
|
+
} catch {
|
|
25
|
+
createApp = null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Validation schema for application registration
|
|
30
|
+
*/
|
|
31
|
+
const registerApplicationSchema = {
|
|
32
|
+
environmentId: (val) => {
|
|
33
|
+
if (!val || val.length < 1) {
|
|
34
|
+
throw new Error('Invalid environment ID format');
|
|
35
|
+
}
|
|
36
|
+
return val;
|
|
37
|
+
},
|
|
38
|
+
key: (val) => {
|
|
39
|
+
if (!val || val.length < 1) {
|
|
40
|
+
throw new Error('Application key is required');
|
|
41
|
+
}
|
|
42
|
+
if (val.length > 50) {
|
|
43
|
+
throw new Error('Application key must be at most 50 characters');
|
|
44
|
+
}
|
|
45
|
+
if (!/^[a-z0-9-]+$/.test(val)) {
|
|
46
|
+
throw new Error('Application key must contain only lowercase letters, numbers, and hyphens');
|
|
47
|
+
}
|
|
48
|
+
return val;
|
|
49
|
+
},
|
|
50
|
+
displayName: (val) => {
|
|
51
|
+
if (!val || val.length < 1) {
|
|
52
|
+
throw new Error('Display name is required');
|
|
53
|
+
}
|
|
54
|
+
if (val.length > 100) {
|
|
55
|
+
throw new Error('Display name must be at most 100 characters');
|
|
56
|
+
}
|
|
57
|
+
return val;
|
|
58
|
+
},
|
|
59
|
+
description: (val) => val || undefined,
|
|
60
|
+
configuration: (val) => {
|
|
61
|
+
const validTypes = ['webapp', 'api', 'service', 'functionapp'];
|
|
62
|
+
const validRegistryModes = ['acr', 'external', 'public'];
|
|
63
|
+
|
|
64
|
+
if (!val || !val.type || !validTypes.includes(val.type)) {
|
|
65
|
+
throw new Error('Configuration type must be one of: webapp, api, service, functionapp');
|
|
66
|
+
}
|
|
67
|
+
if (!val.registryMode || !validRegistryModes.includes(val.registryMode)) {
|
|
68
|
+
throw new Error('Registry mode must be one of: acr, external, public');
|
|
69
|
+
}
|
|
70
|
+
if (val.port !== undefined) {
|
|
71
|
+
if (!Number.isInteger(val.port) || val.port < 1 || val.port > 65535) {
|
|
72
|
+
throw new Error('Port must be an integer between 1 and 65535');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return val;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Load variables.yaml file for an application
|
|
81
|
+
* @async
|
|
82
|
+
* @param {string} appKey - Application key
|
|
83
|
+
* @returns {Promise<{variables: Object, created: boolean}>} Variables and creation flag
|
|
84
|
+
*/
|
|
85
|
+
async function loadVariablesYaml(appKey) {
|
|
86
|
+
const variablesPath = path.join(process.cwd(), 'builder', appKey, 'variables.yaml');
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const variablesContent = await fs.readFile(variablesPath, 'utf-8');
|
|
90
|
+
return { variables: yaml.load(variablesContent), created: false };
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error.code === 'ENOENT') {
|
|
93
|
+
logger.log(chalk.yellow(`ā ļø variables.yaml not found for ${appKey}`));
|
|
94
|
+
logger.log(chalk.yellow('š Creating minimal configuration...\n'));
|
|
95
|
+
return { variables: null, created: true };
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`Failed to read variables.yaml: ${error.message}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Create minimal application configuration if needed
|
|
103
|
+
* @async
|
|
104
|
+
* @param {string} appKey - Application key
|
|
105
|
+
* @param {Object} options - Registration options
|
|
106
|
+
* @returns {Promise<Object>} Variables after creation
|
|
107
|
+
*/
|
|
108
|
+
async function createMinimalAppIfNeeded(appKey, options) {
|
|
109
|
+
if (!createApp) {
|
|
110
|
+
throw new Error('Cannot auto-create application: createApp function not available');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await createApp(appKey, {
|
|
114
|
+
port: options.port,
|
|
115
|
+
language: 'typescript',
|
|
116
|
+
database: false,
|
|
117
|
+
redis: false,
|
|
118
|
+
storage: false,
|
|
119
|
+
authentication: false
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const variablesPath = path.join(process.cwd(), 'builder', appKey, 'variables.yaml');
|
|
123
|
+
const variablesContent = await fs.readFile(variablesPath, 'utf-8');
|
|
124
|
+
return yaml.load(variablesContent);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Extract application configuration from variables.yaml
|
|
129
|
+
* @param {Object} variables - Variables from YAML file
|
|
130
|
+
* @param {string} appKey - Application key
|
|
131
|
+
* @param {Object} options - Registration options
|
|
132
|
+
* @returns {Object} Extracted configuration
|
|
133
|
+
*/
|
|
134
|
+
function extractAppConfiguration(variables, appKey, options) {
|
|
135
|
+
const appKeyFromFile = variables.app?.key || appKey;
|
|
136
|
+
const displayName = variables.app?.name || options.name || appKey;
|
|
137
|
+
const description = variables.app?.description || '';
|
|
138
|
+
const appType = variables.build?.language === 'typescript' ? 'webapp' : 'service';
|
|
139
|
+
const registryMode = 'external';
|
|
140
|
+
const port = variables.build?.port || options.port || 3000;
|
|
141
|
+
const language = variables.build?.language || 'typescript';
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
appKey: appKeyFromFile,
|
|
145
|
+
displayName,
|
|
146
|
+
description,
|
|
147
|
+
appType,
|
|
148
|
+
registryMode,
|
|
149
|
+
port,
|
|
150
|
+
language
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Validate application registration data
|
|
156
|
+
* @param {Object} config - Application configuration
|
|
157
|
+
* @param {string} originalAppKey - Original app key for error messages
|
|
158
|
+
* @throws {Error} If validation fails
|
|
159
|
+
*/
|
|
160
|
+
function validateAppRegistrationData(config, originalAppKey) {
|
|
161
|
+
const missingFields = [];
|
|
162
|
+
if (!config.appKey) missingFields.push('app.key');
|
|
163
|
+
if (!config.displayName) missingFields.push('app.name');
|
|
164
|
+
|
|
165
|
+
if (missingFields.length > 0) {
|
|
166
|
+
logger.error(chalk.red('ā Missing required fields in variables.yaml:'));
|
|
167
|
+
missingFields.forEach(field => logger.error(chalk.red(` - ${field}`)));
|
|
168
|
+
logger.error(chalk.red(`\n Please update builder/${originalAppKey}/variables.yaml and try again.`));
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
registerApplicationSchema.key(config.appKey);
|
|
174
|
+
registerApplicationSchema.displayName(config.displayName);
|
|
175
|
+
registerApplicationSchema.configuration({
|
|
176
|
+
type: config.appType,
|
|
177
|
+
registryMode: config.registryMode,
|
|
178
|
+
port: config.port
|
|
179
|
+
});
|
|
180
|
+
} catch (error) {
|
|
181
|
+
logger.error(chalk.red(`ā Invalid configuration: ${error.message}`));
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Check if user is authenticated
|
|
188
|
+
* @async
|
|
189
|
+
* @returns {Promise<Object>} Configuration with API URL and token
|
|
190
|
+
*/
|
|
191
|
+
async function checkAuthentication() {
|
|
192
|
+
const config = await getConfig();
|
|
193
|
+
if (!config.apiUrl || !config.token) {
|
|
194
|
+
logger.error(chalk.red('ā Not logged in. Run: aifabrix login'));
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
return config;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Call registration API
|
|
202
|
+
* @async
|
|
203
|
+
* @param {string} apiUrl - API URL
|
|
204
|
+
* @param {string} token - Authentication token
|
|
205
|
+
* @param {string} environment - Environment ID
|
|
206
|
+
* @param {Object} registrationData - Registration data
|
|
207
|
+
* @returns {Promise<Object>} API response
|
|
208
|
+
*/
|
|
209
|
+
async function registerApplication(apiUrl, token, environment, registrationData) {
|
|
210
|
+
const response = await authenticatedApiCall(
|
|
211
|
+
`${apiUrl}/api/v1/environments/${encodeURIComponent(environment)}/applications/register`,
|
|
212
|
+
{
|
|
213
|
+
method: 'POST',
|
|
214
|
+
body: JSON.stringify(registrationData)
|
|
215
|
+
},
|
|
216
|
+
token
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
if (!response.success) {
|
|
220
|
+
logger.error(chalk.red(`ā Registration failed: ${response.error}`));
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return response.data;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Display registration success and credentials
|
|
229
|
+
* @param {Object} data - Registration response data
|
|
230
|
+
* @param {string} apiUrl - API URL
|
|
231
|
+
*/
|
|
232
|
+
function displayRegistrationResults(data, apiUrl) {
|
|
233
|
+
logger.log(chalk.green('ā
Application registered successfully!\n'));
|
|
234
|
+
logger.log(chalk.bold('š Application Details:'));
|
|
235
|
+
logger.log(` ID: ${data.application.id}`);
|
|
236
|
+
logger.log(` Key: ${data.application.key}`);
|
|
237
|
+
logger.log(` Display Name: ${data.application.displayName}\n`);
|
|
238
|
+
|
|
239
|
+
logger.log(chalk.bold.yellow('š CREDENTIALS (save these immediately):'));
|
|
240
|
+
logger.log(chalk.yellow(` Client ID: ${data.credentials.clientId}`));
|
|
241
|
+
logger.log(chalk.yellow(` Client Secret: ${data.credentials.clientSecret}\n`));
|
|
242
|
+
|
|
243
|
+
logger.log(chalk.red('ā ļø IMPORTANT: Client Secret will not be shown again!\n'));
|
|
244
|
+
|
|
245
|
+
logger.log(chalk.bold('š Add to GitHub Secrets:'));
|
|
246
|
+
logger.log(chalk.cyan(` AIFABRIX_CLIENT_ID = ${data.credentials.clientId}`));
|
|
247
|
+
logger.log(chalk.cyan(` AIFABRIX_CLIENT_SECRET = ${data.credentials.clientSecret}`));
|
|
248
|
+
logger.log(chalk.cyan(` AIFABRIX_API_URL = ${apiUrl}\n`));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Setup application management commands
|
|
253
|
+
* @param {Command} program - Commander program instance
|
|
254
|
+
*/
|
|
255
|
+
function setupAppCommands(program) {
|
|
256
|
+
const app = program
|
|
257
|
+
.command('app')
|
|
258
|
+
.description('Manage applications');
|
|
259
|
+
|
|
260
|
+
// Register command
|
|
261
|
+
app
|
|
262
|
+
.command('register <appKey>')
|
|
263
|
+
.description('Register application and get pipeline credentials')
|
|
264
|
+
.requiredOption('-e, --environment <env>', 'Environment ID or key')
|
|
265
|
+
.option('-p, --port <port>', 'Application port (default: from variables.yaml)')
|
|
266
|
+
.option('-n, --name <name>', 'Override display name')
|
|
267
|
+
.option('-d, --description <desc>', 'Override description')
|
|
268
|
+
.action(async(appKey, options) => {
|
|
269
|
+
try {
|
|
270
|
+
logger.log(chalk.blue('š Registering application...\n'));
|
|
271
|
+
|
|
272
|
+
// Load variables.yaml
|
|
273
|
+
const { variables, created } = await loadVariablesYaml(appKey);
|
|
274
|
+
let finalVariables = variables;
|
|
275
|
+
|
|
276
|
+
// Create minimal app if needed
|
|
277
|
+
if (created) {
|
|
278
|
+
finalVariables = await createMinimalAppIfNeeded(appKey, options);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Extract configuration
|
|
282
|
+
const appConfig = extractAppConfiguration(finalVariables, appKey, options);
|
|
283
|
+
|
|
284
|
+
// Validate configuration (pass original appKey for error messages)
|
|
285
|
+
validateAppRegistrationData(appConfig, appKey);
|
|
286
|
+
|
|
287
|
+
// Check authentication
|
|
288
|
+
const config = await checkAuthentication();
|
|
289
|
+
|
|
290
|
+
// Validate environment
|
|
291
|
+
const environment = registerApplicationSchema.environmentId(options.environment);
|
|
292
|
+
|
|
293
|
+
// Prepare registration data
|
|
294
|
+
const registrationData = {
|
|
295
|
+
environmentId: environment,
|
|
296
|
+
key: appConfig.appKey,
|
|
297
|
+
displayName: appConfig.displayName,
|
|
298
|
+
description: appConfig.description || options.description,
|
|
299
|
+
configuration: {
|
|
300
|
+
type: appConfig.appType,
|
|
301
|
+
registryMode: appConfig.registryMode,
|
|
302
|
+
port: appConfig.port,
|
|
303
|
+
language: appConfig.language
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// Register application
|
|
308
|
+
const responseData = await registerApplication(
|
|
309
|
+
config.apiUrl,
|
|
310
|
+
config.token,
|
|
311
|
+
environment,
|
|
312
|
+
registrationData
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
// Display results
|
|
316
|
+
displayRegistrationResults(responseData, config.apiUrl);
|
|
317
|
+
|
|
318
|
+
} catch (error) {
|
|
319
|
+
logger.error(chalk.red('ā Registration failed:'), error.message);
|
|
320
|
+
process.exit(1);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// List command
|
|
325
|
+
app
|
|
326
|
+
.command('list')
|
|
327
|
+
.description('List applications')
|
|
328
|
+
.requiredOption('-e, --environment <env>', 'Environment ID or key')
|
|
329
|
+
.action(async(options) => {
|
|
330
|
+
try {
|
|
331
|
+
const config = await getConfig();
|
|
332
|
+
if (!config.apiUrl || !config.token) {
|
|
333
|
+
logger.error(chalk.red('ā Not logged in. Run: aifabrix login'));
|
|
334
|
+
process.exit(1);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const response = await authenticatedApiCall(
|
|
338
|
+
`${config.apiUrl}/api/v1/applications?environmentId=${options.environment}`,
|
|
339
|
+
{},
|
|
340
|
+
config.token
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
if (!response.success || !response.data) {
|
|
344
|
+
logger.error(chalk.red('ā Failed to fetch applications'));
|
|
345
|
+
process.exit(1);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
logger.log(chalk.bold('\nš± Applications:\n'));
|
|
349
|
+
response.data.forEach((app) => {
|
|
350
|
+
const hasPipeline = app.configuration?.pipeline?.isActive ? 'ā' : 'ā';
|
|
351
|
+
logger.log(`${hasPipeline} ${chalk.cyan(app.key)} - ${app.displayName} (${app.status})`);
|
|
352
|
+
});
|
|
353
|
+
logger.log('');
|
|
354
|
+
|
|
355
|
+
} catch (error) {
|
|
356
|
+
logger.error(chalk.red('ā Failed to list applications:'), error.message);
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// Rotate secret command
|
|
362
|
+
app
|
|
363
|
+
.command('rotate-secret')
|
|
364
|
+
.description('Rotate pipeline ClientSecret for an application')
|
|
365
|
+
.requiredOption('-a, --app <appKey>', 'Application key')
|
|
366
|
+
.requiredOption('-e, --environment <env>', 'Environment ID or key')
|
|
367
|
+
.action(async(options) => {
|
|
368
|
+
try {
|
|
369
|
+
logger.log(chalk.yellow('ā ļø This will invalidate the old ClientSecret!\n'));
|
|
370
|
+
|
|
371
|
+
const config = await getConfig();
|
|
372
|
+
if (!config.apiUrl || !config.token) {
|
|
373
|
+
logger.error(chalk.red('ā Not logged in. Run: aifabrix login'));
|
|
374
|
+
process.exit(1);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Validate environment
|
|
378
|
+
if (!options.environment || options.environment.length < 1) {
|
|
379
|
+
logger.error(chalk.red('ā Environment is required'));
|
|
380
|
+
process.exit(1);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const response = await authenticatedApiCall(
|
|
384
|
+
`${config.apiUrl}/api/v1/applications/${options.app}/rotate-secret?environmentId=${options.environment}`,
|
|
385
|
+
{
|
|
386
|
+
method: 'POST'
|
|
387
|
+
},
|
|
388
|
+
config.token
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
if (!response.success) {
|
|
392
|
+
logger.error(chalk.red(`ā Rotation failed: ${response.error}`));
|
|
393
|
+
process.exit(1);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
logger.log(chalk.green('ā
Secret rotated successfully!\n'));
|
|
397
|
+
logger.log(chalk.bold('š Application Details:'));
|
|
398
|
+
logger.log(` Key: ${response.data.application?.key || options.app}`);
|
|
399
|
+
logger.log(` Environment: ${options.environment}\n`);
|
|
400
|
+
|
|
401
|
+
logger.log(chalk.bold.yellow('š NEW CREDENTIALS:'));
|
|
402
|
+
logger.log(chalk.yellow(` Client ID: ${response.data.credentials.clientId}`));
|
|
403
|
+
logger.log(chalk.yellow(` Client Secret: ${response.data.credentials.clientSecret}\n`));
|
|
404
|
+
logger.log(chalk.red('ā ļø Old secret is now invalid. Update GitHub Secrets!\n'));
|
|
405
|
+
|
|
406
|
+
} catch (error) {
|
|
407
|
+
logger.error(chalk.red('ā Rotation failed:'), error.message);
|
|
408
|
+
process.exit(1);
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
module.exports = { setupAppCommands };
|
|
414
|
+
|